================================================================================ FILE: /home/aknuth/git/mailadmin/backend/migrations/001_init.sql ================================================================================ CREATE TABLE IF NOT EXISTS nodes ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, hostname TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE IF NOT EXISTS domains ( id SERIAL PRIMARY KEY, domain TEXT UNIQUE NOT NULL, node_name TEXT, status TEXT NOT NULL DEFAULT 'active', first_seen_at TIMESTAMPTZ DEFAULT now(), last_seen_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE IF NOT EXISTS mailboxes ( id SERIAL PRIMARY KEY, email_address TEXT UNIQUE NOT NULL, domain TEXT NOT NULL, node_name TEXT, status TEXT NOT NULL DEFAULT 'active', used_bytes BIGINT DEFAULT 0, last_usage_scan_at TIMESTAMPTZ, first_seen_at TIMESTAMPTZ DEFAULT now(), last_seen_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE IF NOT EXISTS admin_users ( id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'super_admin', created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE IF NOT EXISTS audit_log ( id SERIAL PRIMARY KEY, actor_email TEXT, action TEXT NOT NULL, target TEXT, details JSONB, created_at TIMESTAMPTZ DEFAULT now() ); -- ============================================================ -- Upgrade existing MVP database -- ============================================================ ALTER TABLE nodes ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE nodes ADD COLUMN IF NOT EXISTS is_current BOOLEAN DEFAULT false; ALTER TABLE domains ADD COLUMN IF NOT EXISTS node_name TEXT; ALTER TABLE domains ADD COLUMN IF NOT EXISTS current_node TEXT; ALTER TABLE domains ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE domains ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ; ALTER TABLE domains ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT ''; ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS node_name TEXT; ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS local_part TEXT; ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS quota_bytes BIGINT; ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS quota_percent NUMERIC(8,3); ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS message_count BIGINT; ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS message_limit BIGINT; ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS usage_scanned_at TIMESTAMPTZ; ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE mailboxes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS allowed_domains TEXT[] NOT NULL DEFAULT '{}'; ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true; ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS target_type TEXT; ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS target_id TEXT; ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS ip_address TEXT; ALTER TABLE audit_log ALTER COLUMN details SET DEFAULT '{}'; -- ============================================================ -- Compatibility backfills -- ============================================================ -- If an earlier version used current_node instead of node_name, copy it. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'domains' AND column_name = 'current_node' ) THEN EXECUTE ' UPDATE domains SET node_name = current_node WHERE node_name IS NULL AND current_node IS NOT NULL '; END IF; END $$; -- If node_name is still empty, set it to current app node from env when available. DO $$ DECLARE app_node_name TEXT; BEGIN app_node_name := current_setting('app.node_name', true); IF app_node_name IS NOT NULL AND app_node_name <> '' THEN UPDATE domains SET node_name = app_node_name WHERE node_name IS NULL; UPDATE mailboxes SET node_name = app_node_name WHERE node_name IS NULL; END IF; END $$; -- Fill current_node from node_name for compatibility with newer code variants. UPDATE domains SET current_node = node_name WHERE current_node IS NULL AND node_name IS NOT NULL; -- Fill local_part for existing rows. UPDATE mailboxes SET local_part = split_part(email_address, '@', 1) WHERE local_part IS NULL AND email_address LIKE '%@%'; -- Fill mailbox node_name from domain node_name where possible. UPDATE mailboxes m SET node_name = d.node_name FROM domains d WHERE m.domain = d.domain AND m.node_name IS NULL AND d.node_name IS NOT NULL; -- Keep old and new usage timestamp columns in sync initially. -- Dynamic EXECUTE is important here, otherwise PostgreSQL may still parse a missing column. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'mailboxes' AND column_name = 'last_usage_scan_at' ) THEN EXECUTE ' UPDATE mailboxes SET usage_scanned_at = last_usage_scan_at WHERE usage_scanned_at IS NULL AND last_usage_scan_at IS NOT NULL '; END IF; END $$; -- Backfill new audit target columns from old target column. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'audit_log' AND column_name = 'target' ) THEN EXECUTE ' UPDATE audit_log SET target_id = target WHERE target_id IS NULL AND target IS NOT NULL '; END IF; END $$; UPDATE audit_log SET target_type = 'unknown' WHERE target_type IS NULL; -- ============================================================ -- Final constraints/defaults after backfill -- ============================================================ ALTER TABLE domains ALTER COLUMN node_name SET DEFAULT 'unknown'; ALTER TABLE mailboxes ALTER COLUMN node_name SET DEFAULT 'unknown'; UPDATE domains SET node_name = 'unknown' WHERE node_name IS NULL; UPDATE mailboxes SET node_name = 'unknown' WHERE node_name IS NULL; ALTER TABLE domains ALTER COLUMN node_name SET NOT NULL; ALTER TABLE mailboxes ALTER COLUMN node_name SET NOT NULL; -- ============================================================ -- Useful indexes -- ============================================================ CREATE INDEX IF NOT EXISTS idx_domains_node_name ON domains(node_name); CREATE INDEX IF NOT EXISTS idx_domains_current_node ON domains(current_node); CREATE INDEX IF NOT EXISTS idx_mailboxes_domain ON mailboxes(domain); CREATE INDEX IF NOT EXISTS idx_mailboxes_node_name ON mailboxes(node_name); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_admin_users_allowed_domains ON admin_users USING GIN(allowed_domains); ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/middleware/auth.ts ================================================================================ import type { NextFunction, Request, Response } from 'express'; import jwt from 'jsonwebtoken'; import { config } from '../config.js'; export interface AuthUser { id: string; email: string; role: string; allowed_domains: string[]; } declare global { namespace Express { interface Request { user?: AuthUser } } } export function signUser(user: AuthUser): string { return jwt.sign(user, config.jwtSecret, { expiresIn: '12h' }); } export function requireAuth(req: Request, res: Response, next: NextFunction): void { const token = req.cookies?.mailadmin_token; if (!token) { res.status(401).json({ error: 'Not authenticated' }); return; } try { req.user = jwt.verify(token, config.jwtSecret) as AuthUser; next(); } catch { res.status(401).json({ error: 'Invalid session' }); } } export function canAccessDomain(user: AuthUser, domain: string): boolean { return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase()); } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/routes/audit.ts ================================================================================ import { Router } from 'express'; import { pool } from '../db.js'; import { requireAuth } from '../middleware/auth.js'; export const auditRouter = Router(); auditRouter.use(requireAuth); auditRouter.get('/', async (_req, res) => { const result = await pool.query( `SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`, ); res.json(result.rows); }); ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/routes/auth.ts ================================================================================ import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { z } from 'zod'; import { pool } from '../db.js'; import { config } from '../config.js'; import { requireAuth, signUser } from '../middleware/auth.js'; export const authRouter = Router(); const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1) }); authRouter.post('/login', async (req, res) => { const body = loginSchema.parse(req.body); const result = await pool.query( `SELECT id, email, password_hash, role, allowed_domains FROM admin_users WHERE email=$1 AND active=true`, [body.email.toLowerCase()], ); const user = result.rows[0]; if (!user || !(await bcrypt.compare(body.password, user.password_hash))) { res.status(401).json({ error: 'Invalid email or password' }); return; } const token = signUser({ id: user.id, email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] }); res.cookie('mailadmin_token', token, { httpOnly: true, sameSite: 'lax', secure: config.cookieSecure, maxAge: 12 * 60 * 60 * 1000, }); res.json({ email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] }); }); authRouter.post('/logout', (_req, res) => { res.clearCookie('mailadmin_token'); res.json({ ok: true }); }); authRouter.get('/me', requireAuth, (req, res) => { res.json(req.user); }); ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/routes/domains.ts ================================================================================ import { Router } from 'express'; import { pool } from '../db.js'; import { config } from '../config.js'; import { requireAuth, canAccessDomain } from '../middleware/auth.js'; import { SyncService } from '../services/sync.js'; import { audit } from '../services/audit.js'; export const domainsRouter = Router(); domainsRouter.use(requireAuth); const sync = new SyncService(); domainsRouter.post('/resync', async (req, res) => { const result = await sync.syncFromDms(); await audit(req.user!.email, 'domains.resync', 'node', config.nodeName, result, req.ip); res.json(result); }); domainsRouter.get('/', async (req, res) => { if (req.query.resync === 'true') await sync.syncFromDms(); const result = await pool.query( `SELECT d.*, COUNT(m.email_address) FILTER (WHERE m.status='active')::int AS active_mailboxes, COALESCE(SUM(m.used_bytes) FILTER (WHERE m.status='active'),0)::bigint AS used_bytes FROM domains d LEFT JOIN mailboxes m ON m.domain=d.domain WHERE d.current_node=$1 GROUP BY d.id ORDER BY d.domain`, [config.nodeName], ); const rows = result.rows.filter((r) => canAccessDomain(req.user!, r.domain)); res.json(rows); }); ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/routes/mailboxes.ts ================================================================================ import { Router } from 'express'; import { z } from 'zod'; import { pool } from '../db.js'; import { config } from '../config.js'; import { requireAuth, canAccessDomain } from '../middleware/auth.js'; import { DmsService } from '../services/dms.js'; import { SyncService } from '../services/sync.js'; import { DynamoRulesService } from '../services/dynamodb.js'; import { audit } from '../services/audit.js'; import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js'; export const mailboxesRouter = Router(); mailboxesRouter.use(requireAuth); const dms = new DmsService(); const sync = new SyncService(dms); const dynamo = new DynamoRulesService(); function ensureDomain(req: any, domain: string): void { if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 }); } async function refreshQuotaForMailbox(emailAddress: string): Promise { const quota = await dms.getMailboxQuota(emailAddress); await pool.query( `UPDATE mailboxes SET used_bytes=$2, quota_bytes=$3, quota_percent=$4, message_count=$5, message_limit=$6, usage_scanned_at=now(), updated_at=now() WHERE email_address=$1`, [ emailAddress, quota.usedBytes, quota.quotaBytes, quota.quotaPercent, quota.messageCount, quota.messageLimit, ], ); } async function refreshQuotaForDomain(req: any, domain: string): Promise { ensureDomain(req, domain); const rows = (await pool.query( `SELECT email_address, domain FROM mailboxes WHERE node_name=$1 AND domain=$2 AND status='active' ORDER BY email_address`, [config.nodeName, domain], )).rows; let count = 0; for (const row of rows) { if (!canAccessDomain(req.user!, row.domain)) continue; try { await refreshQuotaForMailbox(row.email_address); count++; } catch (err) { console.warn(`Could not refresh quota for ${row.email_address}:`, err); } } return count; } mailboxesRouter.get('/', async (req, res) => { const domain = String(req.query.domain ?? '').toLowerCase(); const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true'; if (domain) { ensureDomain(req, domain); if (refreshQuota) { const count = await refreshQuotaForDomain(req, domain); await audit(req.user!.email, 'mailbox.quota_refresh', 'domain', domain, { count }, req.ip); } } const params: unknown[] = [config.nodeName]; let where = 'WHERE node_name=$1'; if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; } const result = await pool.query( `SELECT * FROM mailboxes ${where} ORDER BY domain, local_part`, params, ); res.json(result.rows.filter((r) => canAccessDomain(req.user!, r.domain))); }); mailboxesRouter.post('/', async (req, res) => { const body = z.object({ email: z.string().email(), password: z.string().min(8) }).parse(req.body); const email = normalizeEmail(body.email); const domain = domainFromEmail(email); ensureDomain(req, domain); await dms.addMailbox(email, body.password); await sync.syncFromDms(); await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err)); await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip); res.status(201).json({ email, domain, local_part: localPartFromEmail(email) }); }); mailboxesRouter.delete('/:email', async (req, res) => { const email = normalizeEmail(req.params.email); const domain = domainFromEmail(email); ensureDomain(req, domain); await dms.deleteMailbox(email); await dms.syncSesDomain(domain).catch(() => undefined); await pool.query(`UPDATE mailboxes SET status='deleted', deleted_at=now(), updated_at=now() WHERE email_address=$1`, [email]); await audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip); res.json({ ok: true }); }); mailboxesRouter.post('/:email/password', async (req, res) => { const body = z.object({ password: z.string().min(8) }).parse(req.body); const email = normalizeEmail(req.params.email); ensureDomain(req, domainFromEmail(email)); await dms.updatePassword(email, body.password); await audit(req.user!.email, 'mailbox.password_update', 'mailbox', email, {}, req.ip); res.json({ ok: true }); }); mailboxesRouter.get('/:email/rules', async (req, res) => { const email = normalizeEmail(req.params.email); ensureDomain(req, domainFromEmail(email)); res.json(await dynamo.getRules(email)); }); mailboxesRouter.put('/:email/rules', async (req, res) => { const email = normalizeEmail(req.params.email); ensureDomain(req, domainFromEmail(email)); const body = z.object({ ooo_active: z.boolean().optional(), ooo_message: z.string().optional(), forwards: z.array(z.string().email()).optional() }).parse(req.body); const saved = await dynamo.putRules({ email_address: email, ooo_active: body.ooo_active, ooo_message: body.ooo_message, forwards: body.forwards }); await audit(req.user!.email, 'mailbox.rules_update', 'mailbox', email, saved, req.ip); res.json(saved); }); mailboxesRouter.get('/:email/blocklist', async (req, res) => { const email = normalizeEmail(req.params.email); ensureDomain(req, domainFromEmail(email)); res.json(await dynamo.getBlocklist(email)); }); mailboxesRouter.put('/:email/blocklist', async (req, res) => { const email = normalizeEmail(req.params.email); ensureDomain(req, domainFromEmail(email)); const body = z.object({ blocked_patterns: z.array(z.string()) }).parse(req.body); const saved = await dynamo.putBlocklist(email, body.blocked_patterns); await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip); res.json(saved); }); ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/services/audit.ts ================================================================================ import { pool } from '../db.js'; export async function audit(actorEmail: string | null, action: string, targetType: string, targetId: string, details: unknown, ip?: string): Promise { await pool.query( `INSERT INTO audit_log(actor_email, action, target_type, target_id, details, ip_address) VALUES($1,$2,$3,$4,$5,$6)`, [actorEmail, action, targetType, targetId, details ?? {}, ip ?? null], ); } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/services/dms.ts ================================================================================ import { existsSync } from 'node:fs'; import { config } from '../config.js'; import { run } from '../utils/shell.js'; import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js'; export interface DmsAccount { email: string; localPart: string; domain: string; } export interface DmsQuota { usedBytes: number; quotaBytes: number | null; quotaPercent: number | null; messageCount: number | null; messageLimit: number | null; } 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), }); } 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; } function parseDoveadmQuota(output: string): DmsQuota { let usedStorageKb: number | null = null; let storageLimitKb: number | null = null; let storagePercent: number | null = null; let messageCount: number | null = null; let messageLimit: number | null = null; for (const line of output.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.toLowerCase().startsWith('quota name')) continue; const parts = trimmed.split(/\s+/); const typeIndex = parts.findIndex((p) => p === 'STORAGE' || p === 'MESSAGE'); if (typeIndex < 0) continue; const type = parts[typeIndex]; const value = parseQuotaNumber(parts[typeIndex + 1]); const limit = parseQuotaNumber(parts[typeIndex + 2]); const percent = parseQuotaNumber(parts[typeIndex + 3]); if (type === 'STORAGE') { usedStorageKb = value; storageLimitKb = limit; storagePercent = percent; } if (type === 'MESSAGE') { messageCount = value; messageLimit = limit; } } const usedBytes = Math.max(0, usedStorageKb ?? 0) * 1024; const quotaBytes = storageLimitKb && storageLimitKb > 0 ? storageLimitKb * 1024 : null; const calculatedPercent = quotaBytes && quotaBytes > 0 ? (usedBytes / quotaBytes) * 100 : null; return { usedBytes, quotaBytes, quotaPercent: calculatedPercent ?? storagePercent, messageCount, messageLimit, }; } export class DmsService { async assertDockerAccess(): Promise { await run('docker', ['ps', '--format', '{{.Names}}'], 30000); } async listAccounts(): Promise { 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; } 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], 120000, ); console.log(`[dms] mailbox deleted: ${normalized}`); } async updatePassword(email: string, password: string): Promise { const normalized = normalizeEmail(email); 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)) { 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); } } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/services/dynamodb.ts ================================================================================ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; import { config } from '../config.js'; import { normalizeEmail } from '../utils/email.js'; export interface EmailRule { email_address: string; ooo_active?: boolean; ooo_message?: string; ooo_content_type?: string; forwards?: string[]; } export interface BlockList { email_address: string; blocked_patterns: string[]; } export class DynamoRulesService { private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), { marshallOptions: { removeUndefinedValues: true }, }); async getRules(email: string): Promise { const email_address = normalizeEmail(email); const resp = await this.doc.send(new GetCommand({ TableName: config.rulesTable, Key: { email_address } })); return (resp.Item as EmailRule) ?? { email_address, ooo_active: false, ooo_message: '', ooo_content_type: 'text/plain', forwards: [] }; } async putRules(rule: EmailRule): Promise { const item: EmailRule = { email_address: normalizeEmail(rule.email_address), ooo_active: !!rule.ooo_active, ooo_message: rule.ooo_message ?? '', ooo_content_type: rule.ooo_content_type ?? 'text/plain', forwards: (rule.forwards ?? []).map(normalizeEmail).filter(Boolean), }; await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item })); return item; } async getBlocklist(email: string): Promise { const email_address = normalizeEmail(email); const resp = await this.doc.send(new GetCommand({ TableName: config.blockedTable, Key: { email_address } })); return (resp.Item as BlockList) ?? { email_address, blocked_patterns: [] }; } async putBlocklist(email: string, patterns: string[]): Promise { const item: BlockList = { email_address: normalizeEmail(email), blocked_patterns: patterns.map((p) => p.trim().toLowerCase()).filter(Boolean), }; if (item.blocked_patterns.length === 0) { await this.doc.send(new DeleteCommand({ TableName: config.blockedTable, Key: { email_address: item.email_address } })); return item; } await this.doc.send(new PutCommand({ TableName: config.blockedTable, Item: item })); return item; } } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/services/sync.ts ================================================================================ import { pool } from '../db.js'; import { config } from '../config.js'; import { DmsService } from './dms.js'; export class SyncService { constructor(private dms = new DmsService()) {} async syncFromDms(): Promise<{ domains: number; mailboxes: number }> { const accounts = await this.dms.listAccounts(); const domains = [...new Set(accounts.map((a) => a.domain))].sort(); await pool.query('BEGIN'); try { for (const domain of domains) { await pool.query( `INSERT INTO domains(domain, current_node, status, last_seen_at, last_synced_at) VALUES($1,$2,'active',now(),now()) ON CONFLICT(domain) DO UPDATE SET current_node = EXCLUDED.current_node, status = 'active', last_seen_at = now(), last_synced_at = now(), updated_at = now()`, [domain, config.nodeName], ); } const localEmails = accounts.map((a) => a.email); for (const account of accounts) { await pool.query( `INSERT INTO mailboxes(email_address, local_part, domain, node_name, status, deleted_at) VALUES($1,$2,$3,$4,'active',NULL) ON CONFLICT(email_address) DO UPDATE SET local_part = EXCLUDED.local_part, domain = EXCLUDED.domain, node_name = EXCLUDED.node_name, status = 'active', deleted_at = NULL, updated_at = now()`, [account.email, account.localPart, account.domain, config.nodeName], ); } await pool.query( `UPDATE domains SET status='missing_on_node', last_synced_at=now(), updated_at=now() WHERE current_node=$1 AND NOT (domain = ANY($2::text[]))`, [config.nodeName, domains], ); await pool.query( `UPDATE mailboxes SET status='missing_on_node', updated_at=now() WHERE node_name=$1 AND status='active' AND NOT (email_address = ANY($2::text[]))`, [config.nodeName, localEmails], ); await pool.query('COMMIT'); return { domains: domains.length, mailboxes: accounts.length }; } catch (err) { await pool.query('ROLLBACK'); throw err; } } } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/utils/email.ts ================================================================================ export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } export function domainFromEmail(email: string): string { const normalized = normalizeEmail(email); const at = normalized.lastIndexOf('@'); if (at < 1 || at === normalized.length - 1) throw new Error('Invalid email address'); return normalized.slice(at + 1); } export function localPartFromEmail(email: string): string { const normalized = normalizeEmail(email); const at = normalized.lastIndexOf('@'); if (at < 1) throw new Error('Invalid email address'); return normalized.slice(0, at); } export function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = bytes; let i = 0; while (value >= 1024 && i < units.length - 1) { value /= 1024; i++; } return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/utils/shell.ts ================================================================================ import { spawn } from 'node:child_process'; export interface RunResult { stdout: string; stderr: string; code: number; } export function run( command: string, args: string[] = [], timeoutMs = 120000, ): Promise { return new Promise((resolve, reject) => { 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; } console.log(`[shell] completed: ${printable}`); resolve({ stdout, stderr, code: exitCode }); }); }); } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/config.ts ================================================================================ import 'dotenv/config'; export const config = { env: process.env.NODE_ENV ?? 'development', port: parseInt(process.env.PORT ?? '3000', 10), publicDir: process.env.PUBLIC_DIR ?? '../frontend', databaseUrl: process.env.DATABASE_URL ?? 'postgres://mailadmin:mailadmin@localhost:5432/mailadmin', jwtSecret: process.env.JWT_SECRET ?? 'dev-secret-change-me', cookieSecure: (process.env.COOKIE_SECURE ?? 'false').toLowerCase() === 'true', adminEmail: process.env.ADMIN_EMAIL ?? 'admin@example.com', adminPassword: process.env.ADMIN_PASSWORD ?? 'ChangeMe123!', nodeName: process.env.NODE_NAME ?? 'node1', nodeHostname: process.env.NODE_HOSTNAME ?? 'node1.email-srvr.com', dmsContainer: process.env.DMS_CONTAINER ?? 'mailserver', manageMailUserScript: process.env.MANAGE_MAIL_USER_SCRIPT ?? '', mailDataPath: process.env.MAILDATA_PATH ?? '/mail-data', awsRegion: process.env.AWS_REGION ?? 'us-east-2', rulesTable: process.env.DYNAMODB_RULES_TABLE ?? 'email-rules', blockedTable: process.env.DYNAMODB_BLOCKED_TABLE ?? 'email-blocked-senders', }; ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/db.ts ================================================================================ import pg from 'pg'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const { Pool } = pg; export const pool = new Pool({ connectionString: process.env.DATABASE_URL, }); function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getBackendRootDir(): string { const currentFile = fileURLToPath(import.meta.url); const currentDir = path.dirname(currentFile); /** * During development: * backend/src/db.ts * * After TypeScript build: * backend/dist/db.js * * We want: * backend/ */ if (currentDir.endsWith('/src') || currentDir.endsWith('\\src')) { return path.resolve(currentDir, '..'); } if (currentDir.endsWith('/dist') || currentDir.endsWith('\\dist')) { return path.resolve(currentDir, '..'); } return path.resolve(currentDir, '..'); } async function tableExists(tableName: string): Promise { const result = await pool.query( ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 ) AS exists `, [tableName], ); return Boolean(result.rows[0]?.exists); } async function runMigrations() { await pool.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( id SERIAL PRIMARY KEY, filename TEXT UNIQUE NOT NULL, executed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); const backendRoot = getBackendRootDir(); const migrationsDir = path.join(backendRoot, 'migrations'); let files: string[] = []; try { files = await fs.readdir(migrationsDir); } catch (err: any) { throw new Error( `Migrations directory not found: ${migrationsDir}. ` + `Make sure backend/migrations is copied into the Docker image. Original error: ${err.message}`, ); } const sqlFiles = files .filter((file) => file.endsWith('.sql')) .sort((a, b) => a.localeCompare(b)); for (const file of sqlFiles) { const alreadyExecuted = await pool.query( `SELECT 1 FROM schema_migrations WHERE filename = $1`, [file], ); /** * Special case: * The first MVP version created tables inline in db.ts and had no migration system. * Therefore 001_init.sql must still run once to apply ALTER TABLE upgrades. * * For later migrations this mechanism prevents duplicate execution. */ const shouldRun = alreadyExecuted.rowCount === 0 || file === '001_init.sql'; if (!shouldRun) { continue; } const fullPath = path.join(migrationsDir, file); const sql = await fs.readFile(fullPath, 'utf8'); console.log(`Running database migration: ${file}`); await pool.query('BEGIN'); try { await pool.query(sql); await pool.query( ` INSERT INTO schema_migrations(filename) VALUES ($1) ON CONFLICT (filename) DO UPDATE SET executed_at = now() `, [file], ); await pool.query('COMMIT'); console.log(`✓ Migration completed: ${file}`); } catch (err) { await pool.query('ROLLBACK'); console.error(`✗ Migration failed: ${file}`); throw err; } } } async function seedInitialAdmin() { const adminEmail = process.env.MAILADMIN_ADMIN_EMAIL; const adminPassword = process.env.MAILADMIN_ADMIN_PASSWORD; if (!adminEmail || !adminPassword) { console.warn( 'MAILADMIN_ADMIN_EMAIL or MAILADMIN_ADMIN_PASSWORD is not set. Skipping initial admin creation.', ); return; } const existing = await pool.query( `SELECT id FROM admin_users WHERE email = $1`, [adminEmail.toLowerCase()], ); if ((existing.rowCount ?? 0) > 0) { return; } /** * bcryptjs is already used by the auth route in the MVP. * Dynamic import avoids requiring it before DB startup. */ const bcrypt = await import('bcryptjs'); const passwordHash = await bcrypt.hash(adminPassword, 12); await pool.query( ` INSERT INTO admin_users ( email, password_hash, role, allowed_domains, active ) VALUES ($1, $2, 'super_admin', '{}', true) `, [adminEmail.toLowerCase(), passwordHash], ); console.log(`✓ Initial super admin created: ${adminEmail}`); } export async function initDb() { const maxAttempts = 30; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { await pool.query('SELECT 1'); console.log('✓ PostgreSQL connected'); await runMigrations(); const hasAdminUsers = await tableExists('admin_users'); if (hasAdminUsers) { await seedInitialAdmin(); } return; } catch (err: any) { console.warn( `PostgreSQL not ready yet (${attempt}/${maxAttempts}): ${err.message}`, ); if (attempt === maxAttempts) { throw err; } await sleep(2000); } } } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/src/server.ts ================================================================================ import express from 'express'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import { dirname, resolve } 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(cookieParser()); 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.get('/api/health', (_req, res) => { res.json({ ok: true, node: config.nodeName, hostname: config.nodeHostname, }); }); 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 ?? 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', }); }); const publicDir = config.publicDir.startsWith('/') ? config.publicDir : resolve(__dirname, config.publicDir); console.log(`Serving frontend from: ${publicDir}`); // Avoid stale frontend JS while we are actively developing the MVP. app.use((req, res, next) => { if ( req.path.endsWith('.js') || req.path.endsWith('.css') || req.path.endsWith('.html') ) { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } next(); }); app.use(express.static(publicDir, { etag: false, lastModified: false, })); app.get('*', (_req, res) => { res.sendFile(resolve(publicDir, 'index.html')); }); 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}`); }); ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/.env.example ================================================================================ NODE_ENV=production PORT=3000 PUBLIC_DIR=/app/frontend DATABASE_URL=postgres://mailadmin:change-me@mailadmin-db:5432/mailadmin JWT_SECRET=change-this-long-random-secret COOKIE_SECURE=true # Initial admin is created if it does not exist. ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=ChangeMe123! # This node identity. Run the same app on node2 with NODE_NAME=node2. NODE_NAME=node1 NODE_HOSTNAME=node1.email-srvr.com # DMS integration DMS_CONTAINER=mailserver # Optional: if present on host/container, add/del/sync use this script instead of direct docker exec. MANAGE_MAIL_USER_SCRIPT=/opt/email-amazon/basic_setup/manage_mail_user.sh MAILDATA_PATH=/mail-data # AWS / DynamoDB AWS_REGION=us-east-2 DYNAMODB_RULES_TABLE=email-rules DYNAMODB_BLOCKED_TABLE=email-blocked-senders ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/Dockerfile ================================================================================ FROM node:22-bookworm AS build WORKDIR /app/backend COPY backend/package*.json ./ RUN npm install COPY backend ./ RUN npm run build FROM node:22-bookworm-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends docker.io awscli jq bash coreutils ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=build /app/backend/node_modules ./backend/node_modules COPY --from=build /app/backend/dist ./backend/dist COPY backend/migrations ./backend/migrations COPY frontend ./frontend WORKDIR /app/backend ENV NODE_ENV=production PUBLIC_DIR=/app/frontend CMD ["node", "dist/server.js"] ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/package.json ================================================================================ { "name": "mailadmin-backend", "version": "0.1.0", "type": "module", "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/server.js" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.731.1", "@aws-sdk/lib-dynamodb": "^3.731.1", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", "pg": "^8.13.1", "zod": "^3.24.1" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.6", "@types/pg": "^8.11.10", "tsx": "^4.19.2", "typescript": "^5.7.3" } } ================================================================================ FILE: /home/aknuth/git/mailadmin/backend/tsconfig.json ================================================================================ { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*.ts"] } ================================================================================ FILE: /home/aknuth/git/mailadmin/frontend/app.js ================================================================================ const app = document.getElementById('app'); const state = { user: null, domains: [], selectedDomain: null, mailboxes: [], audit: [], message: '', error: '' }; async function api(path, options = {}) { const res = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } return res.json(); } function bytes(n) { n = Number(n || 0); if (n <= 0) return '0 B'; const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; } function percent(n) { if (n === null || n === undefined || Number.isNaN(Number(n))) return null; const value = Number(n); if (value < 1 && value > 0) return value.toFixed(1); return value.toFixed(0); } function usageInfo(m) { const used = Number(m.used_bytes || 0); const limit = m.quota_bytes === null || m.quota_bytes === undefined ? null : Number(m.quota_bytes); const calculated = limit && limit > 0 ? (used / limit) * 100 : null; const rawPct = m.quota_percent === null || m.quota_percent === undefined ? calculated : Number(m.quota_percent); const pct = rawPct === null || Number.isNaN(rawPct) ? null : Math.max(0, Math.min(100, rawPct)); return { used, limit, pct, label: limit ? `${bytes(used)} / ${bytes(limit)} (${percent(pct)}%)` : `${bytes(used)} / unlimited`, }; } function esc(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); } async function init() { try { state.user = await api('/api/auth/me'); await loadDomains(true); } catch { state.user = null; } render(); } async function loadDomains(resync = false) { state.domains = await api(`/api/domains${resync ? '?resync=true' : ''}`); if (!state.selectedDomain && state.domains.length) state.selectedDomain = state.domains[0].domain; if (state.selectedDomain) await loadMailboxes(true); } async function loadMailboxes(refreshQuota = false) { if (!state.selectedDomain) { state.mailboxes = []; return; } state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}${refreshQuota ? '&refreshQuota=true' : ''}`); } async function loadAudit() { state.audit = await api('/api/audit'); renderAuditModal(); } function renderUsage(m) { const usage = usageInfo(m); const barWidth = usage.pct === null ? 0 : usage.pct; return `
Disk Usage: ${esc(usage.label)}
${m.usage_scanned_at ? `quota checked ${new Date(m.usage_scanned_at).toLocaleString()}` : 'quota not checked yet'} ${m.message_count !== null && m.message_count !== undefined ? ` · ${Number(m.message_count)} messages` : ''}
`; } function render() { if (!state.user) return renderLogin(); app.innerHTML = `
MailAdmin
${esc(state.user.email)} · ${esc(state.user.role)}
${state.error ? `
${esc(state.error)}
` : ''} ${state.message ? `
${esc(state.message)}
` : ''}

Domains on this node

Domains are discovered dynamically from DMS accounts.
${state.domains.map(d => `
${esc(d.domain)}
${d.active_mailboxes || 0} inboxes · ${bytes(d.used_bytes)}
${esc(d.current_node)} ${esc(d.status)}
`).join('') || '
No domains found yet.
'}

${esc(state.selectedDomain || 'Mailboxes')}

Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
${state.mailboxes.map(m => ` `).join('') || ''}
EmailStatusUsageUpdatedActions
${esc(m.email_address)}
${esc(m.node_name)}
${esc(m.status)} ${renderUsage(m)} ${new Date(m.updated_at).toLocaleString()}
No mailboxes for this domain.
`; document.getElementById('logoutBtn').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); state.user = null; render(); }; document.getElementById('resyncBtn').onclick = guard(async () => { await api('/api/domains/resync', { method:'POST' }); await loadDomains(false); state.message = 'DMS sync completed.'; render(); }); document.getElementById('auditBtn').onclick = guard(loadAudit); document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal; document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(true); render(); })); document.querySelectorAll('[data-delete]').forEach(el => el.onclick = () => renderDeleteModal(el.dataset.delete)); document.querySelectorAll('[data-password]').forEach(el => el.onclick = () => renderPasswordModal(el.dataset.password)); document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules)); document.querySelectorAll('[data-blocks]').forEach(el => el.onclick = () => renderBlocklistModal(el.dataset.blocks)); } function renderLogin() { app.innerHTML = ``; document.getElementById('loginForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); state.user = await api('/api/auth/login', { method:'POST', body: JSON.stringify({ email: f.get('email'), password: f.get('password') }) }); await loadDomains(true); render(); }); } function modal(html) { const div = document.createElement('div'); div.className = 'modal-backdrop'; div.innerHTML = ``; document.body.appendChild(div); div.querySelector('[data-close]').onclick = () => div.remove(); div.onclick = e => { if (e.target === div) div.remove(); }; return div; } function renderCreateMailboxModal() { const domain = state.selectedDomain || ''; const d = modal(`

New mailbox

`); const emailInput = d.querySelector('#createEmail'); const passwordInput = d.querySelector('#createPassword'); const submitButton = d.querySelector('#createMailboxSubmit'); emailInput.focus(); // Cursor before the @domain, so you can directly type "test". try { emailInput.setSelectionRange(0, 0); } catch { // Some browsers do not allow selection on email inputs. } const createMailbox = guard(async () => { const email = String(emailInput.value || '').trim().toLowerCase(); const password = String(passwordInput.value || ''); if (!email || !email.includes('@')) { throw new Error('Please enter a valid email address.'); } if (!email.endsWith(`@${domain}`)) { throw new Error(`Mailbox must belong to ${domain}.`); } if (password.length < 8) { throw new Error('Password must have at least 8 characters.'); } submitButton.disabled = true; submitButton.textContent = 'Creating...'; await api('/api/mailboxes', { method: 'POST', body: JSON.stringify({ email, password, }), }); d.remove(); state.message = `Mailbox created: ${email}`; await loadDomains(false); await loadMailboxes(true); render(); }); submitButton.onclick = createMailbox; passwordInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); createMailbox(); } }); emailInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); passwordInput.focus(); } }); } function renderDeleteModal(email) { const d = modal(`

Delete mailbox

Delete ${esc(email)} from DMS?

`); d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(true); render(); }); } function renderPasswordModal(email) { const d = modal(`

Reset password



`); d.querySelector('#pwForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/password`, { method:'POST', body: JSON.stringify({ password:f.get('password') }) }); d.remove(); state.message = `Password updated for ${email}.`; render(); }); } async function renderRulesModal(email) { const rules = await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`); const d = modal(`

Rules for ${esc(email)}







`); d.querySelector('#rulesForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`, { method:'PUT', body: JSON.stringify({ ooo_active: !!f.get('ooo_active'), ooo_message:f.get('ooo_message'), forwards:String(f.get('forwards')||'').split(/\n|,/).map(x=>x.trim()).filter(Boolean) }) }); d.remove(); }); } async function renderBlocklistModal(email) { const block = await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`); const d = modal(`

Blocklist for ${esc(email)}



`); d.querySelector('#blockForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { method:'PUT', body: JSON.stringify({ blocked_patterns:String(f.get('blocked_patterns')||'').split('\n').map(x=>x.trim()).filter(Boolean) }) }); d.remove(); }); } function renderAuditModal() { modal(`

Audit Log

${state.audit.map(a => ``).join('')}
TimeActorActionTarget
${new Date(a.created_at).toLocaleString()}${esc(a.actor_email)}${esc(a.action)}${esc(a.target_id)}
`); } function guard(fn) { return async function(...args) { try { state.error=''; state.message=''; await fn.apply(this,args); } catch(e) { state.error = e.message; render(); } }; } init(); ================================================================================ FILE: /home/aknuth/git/mailadmin/frontend/index.html ================================================================================ MailAdmin
================================================================================ FILE: /home/aknuth/git/mailadmin/frontend/styles.css ================================================================================ :root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; --ok:#059669; } * { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--text); } button, input, textarea, select { font: inherit; } button { border: 0; border-radius: 10px; padding: 10px 14px; background: var(--accent); color: white; cursor: pointer; } button.secondary { background: #eef2ff; color: #1e3a8a; } button.danger { background: var(--danger); } button.ghost { background: transparent; color: var(--accent); } input, textarea, select { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: white; } textarea { min-height: 110px; } .header { display:flex; justify-content:space-between; align-items:center; padding: 18px 28px; background: white; border-bottom:1px solid var(--line); position: sticky; top: 0; z-index: 5; } .brand { font-weight: 800; font-size: 20px; } .container { max-width: 1280px; margin: 0 auto; padding: 24px; } .grid { display:grid; grid-template-columns: 320px 1fr; gap: 20px; align-items:start; } .card { background: var(--card); border:1px solid var(--line); border-radius: 18px; padding: 18px; box-shadow: 0 10px 25px rgba(15,23,42,.04); } .card h2 { margin:0 0 14px; font-size: 18px; } .list { display:flex; flex-direction:column; gap: 8px; } .list-item { border:1px solid var(--line); border-radius: 14px; padding: 12px; background:#fff; cursor:pointer; } .list-item.active { border-color: var(--accent); background:#eff6ff; } .muted { color: var(--muted); font-size: 13px; } .row { display:flex; gap: 10px; align-items:center; } .row > * { flex: 1; } .table { width:100%; border-collapse: collapse; } .table th, .table td { text-align:left; padding: 10px; border-bottom:1px solid var(--line); vertical-align: top; } .table th { font-size:12px; color:var(--muted); text-transform: uppercase; letter-spacing:.04em; } .pill { display:inline-flex; align-items:center; padding: 3px 8px; border-radius: 999px; background:#f3f4f6; font-size:12px; } .actions { display:flex; flex-wrap:wrap; gap:8px; } .login { min-height: 100vh; display:grid; place-items:center; padding:24px; } .login .card { width:min(420px, 100%); } .error { color: var(--danger); margin: 10px 0; } .success { color: #047857; margin: 10px 0; } .modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.35); display:grid; place-items:center; padding: 20px; z-index: 10; } .modal { width:min(720px, 100%); max-height: 90vh; overflow:auto; } .form-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; } .usage-cell { min-width: 220px; } .usage-label { font-size: 13px; font-weight: 650; margin-bottom: 6px; } .usage-bar { width: 100%; height: 10px; border-radius: 999px; background: #e5e7eb; overflow: hidden; border: 1px solid #d1d5db; } .usage-bar-fill { height: 100%; border-radius: 999px; background: var(--ok); transition: width .25s ease; } @media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } } ================================================================================ FILE: /home/aknuth/git/mailadmin/.env.example ================================================================================ MAILADMIN_DB_PASSWORD=change-me MAILADMIN_JWT_SECRET=replace-with-long-random-secret MAILADMIN_ADMIN_EMAIL=admin@bayarea-cc.com MAILADMIN_ADMIN_PASSWORD=ChangeMe123! NODE_NAME=node1 NODE_HOSTNAME=node1.email-srvr.com DMS_CONTAINER=mailserver AWS_REGION=us-east-2 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= ================================================================================ FILE: /home/aknuth/git/mailadmin/docker-compose.yml ================================================================================ services: mailadmin-db: image: postgres:16 container_name: mailadmin-db environment: POSTGRES_DB: mailadmin POSTGRES_USER: mailadmin POSTGRES_PASSWORD: ${MAILADMIN_DB_PASSWORD} volumes: - ./data/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U mailadmin -d mailadmin"] interval: 5s timeout: 5s retries: 20 start_period: 10s restart: unless-stopped networks: - mailadmin_network mailadmin: build: context: . dockerfile: backend/Dockerfile container_name: mailadmin restart: unless-stopped depends_on: mailadmin-db: condition: service_healthy environment: NODE_ENV: production PORT: 3000 PUBLIC_DIR: /app/frontend DATABASE_URL: postgres://mailadmin:${MAILADMIN_DB_PASSWORD:-change-me}@mailadmin-db:5432/mailadmin JWT_SECRET: ${MAILADMIN_JWT_SECRET:-change-this-long-random-secret} COOKIE_SECURE: "true" ADMIN_EMAIL: ${MAILADMIN_ADMIN_EMAIL:-admin@example.com} ADMIN_PASSWORD: ${MAILADMIN_ADMIN_PASSWORD:-ChangeMe123!} NODE_NAME: ${NODE_NAME:-node1} NODE_HOSTNAME: ${NODE_HOSTNAME:-node1.email-srvr.com} DMS_CONTAINER: ${DMS_CONTAINER:-mailserver} MANAGE_MAIL_USER_SCRIPT: /host/email-amazon/basic_setup/manage_mail_user.sh MAILDATA_PATH: /mail-data AWS_REGION: ${AWS_REGION:-us-east-2} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} DYNAMODB_RULES_TABLE: ${DYNAMODB_RULES_TABLE:-email-rules} DYNAMODB_BLOCKED_TABLE: ${DYNAMODB_BLOCKED_TABLE:-email-blocked-senders} volumes: # Needed so backend can call docker exec mailserver. - /var/run/docker.sock:/var/run/docker.sock # Adjust this path to your repository root on the host. - /home/aknuth/git/email-amazon:/host/email-amazon:ro # Adjust this path to your DMS mail-data directory. - /home/aknuth/git/email-amazon/DMS/docker-data/dms/mail-data:/mail-data:ro networks: - mailadmin_network networks: mailadmin_network: external: true