
================================================================================
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<void> {
  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<number> {
  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<void> {
  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<void> {
    await run('docker', ['ps', '--format', '{{.Names}}'], 30000);
  }

  async listAccounts(): Promise<DmsAccount[]> {
    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;
    }

    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],
      120000,
    );

    console.log(`[dms] mailbox deleted: ${normalized}`);
  }

  async updatePassword(email: string, password: string): Promise<void> {
    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<void> {
    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<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);
  }
}

================================================================================
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<EmailRule> {
    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<EmailRule> {
    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<BlockList> {
    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<BlockList> {
    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<RunResult> {
  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<boolean> {
  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 => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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 `
    <div class="usage-cell">
      <div class="usage-label">Disk Usage: ${esc(usage.label)}</div>
      <div class="usage-bar" aria-label="Disk Usage ${esc(usage.label)}">
        <div class="usage-bar-fill" style="width:${barWidth}%"></div>
      </div>
      <div class="muted">
        ${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` : ''}
      </div>
    </div>`;
}

function render() {
  if (!state.user) return renderLogin();
  app.innerHTML = `
    <div class="header">
      <div><div class="brand">MailAdmin</div><div class="muted">${esc(state.user.email)} · ${esc(state.user.role)}</div></div>
      <div class="actions">
        <button class="secondary" id="auditBtn">Audit Log</button>
        <button class="secondary" id="resyncBtn">DMS Resync</button>
        <button class="ghost" id="logoutBtn">Logout</button>
      </div>
    </div>
    <div class="container">
      ${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}
      ${state.message ? `<div class="success">${esc(state.message)}</div>` : ''}
      <div class="grid">
        <section class="card">
          <h2>Domains on this node</h2>
          <div class="muted" style="margin-bottom:12px">Domains are discovered dynamically from DMS accounts.</div>
          <div class="list">
            ${state.domains.map(d => `
              <div class="list-item ${d.domain === state.selectedDomain ? 'active' : ''}" data-domain="${esc(d.domain)}">
                <strong>${esc(d.domain)}</strong><br>
                <span class="muted">${d.active_mailboxes || 0} inboxes · ${bytes(d.used_bytes)}</span><br>
                <span class="pill">${esc(d.current_node)}</span> <span class="pill">${esc(d.status)}</span>
              </div>`).join('') || '<div class="muted">No domains found yet.</div>'}
          </div>
        </section>
        <section class="card">
          <div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:14px">
            <div><h2 style="margin:0">${esc(state.selectedDomain || 'Mailboxes')}</h2><div class="muted">Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.</div></div>
            <div class="actions"><button id="newMailboxBtn">New mailbox</button></div>
          </div>
          <table class="table">
            <thead><tr><th>Email</th><th>Status</th><th>Usage</th><th>Updated</th><th>Actions</th></tr></thead>
            <tbody>
              ${state.mailboxes.map(m => `
                <tr>
                  <td><strong>${esc(m.email_address)}</strong><div class="muted">${esc(m.node_name)}</div></td>
                  <td><span class="pill">${esc(m.status)}</span></td>
                  <td>${renderUsage(m)}</td>
                  <td class="muted">${new Date(m.updated_at).toLocaleString()}</td>
                  <td><div class="actions">
                    <button class="secondary" data-rules="${esc(m.email_address)}">Rules</button>
                    <button class="secondary" data-blocks="${esc(m.email_address)}">Blocklist</button>
                    <button class="secondary" data-password="${esc(m.email_address)}">Password</button>
                    <button class="danger" data-delete="${esc(m.email_address)}">Delete</button>
                  </div></td>
                </tr>`).join('') || '<tr><td colspan="5" class="muted">No mailboxes for this domain.</td></tr>'}
            </tbody>
          </table>
        </section>
      </div>
    </div>`;

  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 = `<div class="login"><div class="card"><h2>MailAdmin Login</h2>${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}<form id="loginForm"><label>Email<br><input name="email" type="email" required></label><br><br><label>Password<br><input name="password" type="password" required></label><br><br><button>Login</button></form></div></div>`;
  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 = `<div class="card modal">${html}<div style="margin-top:14px"><button class="ghost" data-close>Close</button></div></div>`;
  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(`
    <h2>New mailbox</h2>

    <div class="form-grid">
      <label>
        Email
        <input
          id="createEmail"
          type="email"
          value="@${esc(domain)}"
          placeholder="@${esc(domain)}"
          autocomplete="off"
          required
        >
      </label>

      <label>
        Password
        <input
          id="createPassword"
          type="password"
          value=""
          minlength="8"
          autocomplete="new-password"
          required
        >
      </label>

      <div>
        <button type="button" id="createMailboxSubmit">Create</button>
      </div>
    </div>
  `);

  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(`<h2>Delete mailbox</h2><p>Delete <strong>${esc(email)}</strong> from DMS?</p><button class="danger" id="confirmDelete">Delete</button>`);
  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(`<h2>Reset password</h2><form id="pwForm"><label>New password<input name="password" type="password" minlength="8" required></label><br><br><button>Update password</button></form>`);
  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(`<h2>Rules for ${esc(email)}</h2><form id="rulesForm"><label><input type="checkbox" name="ooo_active" ${rules.ooo_active ? 'checked' : ''} style="width:auto"> Auto reply active</label><br><br><label>Auto reply message<textarea name="ooo_message">${esc(rules.ooo_message || '')}</textarea></label><br><br><label>Forwards, one email per line<textarea name="forwards">${esc((rules.forwards || []).join('\n'))}</textarea></label><br><br><button>Save rules</button></form>`);
  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(`<h2>Blocklist for ${esc(email)}</h2><form id="blockForm"><label>Patterns, one per line<br><span class="muted">Examples: spam@example.com, *@bad-domain.com</span><textarea name="blocked_patterns">${esc((block.blocked_patterns || []).join('\n'))}</textarea></label><br><br><button>Save blocklist</button></form>`);
  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(`<h2>Audit Log</h2><table class="table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead><tbody>${state.audit.map(a => `<tr><td class="muted">${new Date(a.created_at).toLocaleString()}</td><td>${esc(a.actor_email)}</td><td>${esc(a.action)}</td><td>${esc(a.target_id)}</td></tr>`).join('')}</tbody></table>`);
}

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
================================================================================
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>MailAdmin</title>
  <link rel="stylesheet" href="/styles.css" />
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/app.js"></script>
</body>
</html>


================================================================================
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

