From 844c63dd85a9c2786c3ff80449ccce8a2ab64c81 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Sun, 26 Apr 2026 13:47:35 -0500 Subject: [PATCH] initial commit --- .env.example | 10 + README.md | 191 ++++++++++++++++++ backend/.env.example | 25 +++ backend/Dockerfile | 17 ++ backend/migrations/001_init.sql | 63 ++++++ backend/package.json | 33 +++ backend/src/config.ts | 24 +++ backend/src/db.ts | 32 +++ backend/src/middleware/auth.ts | 38 ++++ backend/src/routes/audit.ts | 13 ++ backend/src/routes/auth.ts | 40 ++++ backend/src/routes/domains.ts | 33 +++ backend/src/routes/mailboxes.ts | 113 +++++++++++ backend/src/server.ts | 46 +++++ backend/src/services/audit.ts | 9 + backend/src/services/dms.ts | 83 ++++++++ backend/src/services/dynamodb.ts | 60 ++++++ backend/src/services/sync.ts | 65 ++++++ backend/src/utils/email.ts | 29 +++ backend/src/utils/shell.ts | 21 ++ backend/tsconfig.json | 13 ++ deploy/caddy/mailadmin-snippet.caddy | 7 + .../update-caddy-certs-mailadmin.patch.sh | 12 ++ docker-compose.yml | 54 +++++ frontend/app.js | 162 +++++++++++++++ frontend/index.html | 13 ++ frontend/styles.css | 35 ++++ 27 files changed, 1241 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/package.json create mode 100644 backend/src/config.ts create mode 100644 backend/src/db.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/routes/audit.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/domains.ts create mode 100644 backend/src/routes/mailboxes.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/services/audit.ts create mode 100644 backend/src/services/dms.ts create mode 100644 backend/src/services/dynamodb.ts create mode 100644 backend/src/services/sync.ts create mode 100644 backend/src/utils/email.ts create mode 100644 backend/src/utils/shell.ts create mode 100644 backend/tsconfig.json create mode 100644 deploy/caddy/mailadmin-snippet.caddy create mode 100755 deploy/scripts/update-caddy-certs-mailadmin.patch.sh create mode 100644 docker-compose.yml create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/styles.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9c61aec --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +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= diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbff295 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# MailAdmin MVP + +Erste Version einer Admin-Webapp für dein DMS/SES/S3/SQS/DynamoDB-Mail-System. + +Die App ist bewusst **node-orientiert** gebaut: Sie läuft auf `node1`, `node2`, usw. und entdeckt die Domains dynamisch aus dem lokalen Docker Mailserver. Dadurch ist sie geeignet für dein Szenario, bei dem Domains neu hinzukommen oder zwischen Nodes umziehen. + +## Was diese Version bereits kann + +- Login mit eigenem Admin-User +- PostgreSQL als Admin-Datenbank +- dynamische Domain-Erkennung über `docker exec mailserver setup email list` +- Domains pro Node speichern: `node1`, `node2`, ... +- Mailboxen anzeigen +- Mailbox anlegen +- Mailbox löschen +- Passwort zurücksetzen +- Speicherverbrauch pro Inbox scannen +- Forward- und Auto-Reply-Regeln in DynamoDB bearbeiten +- Blocklisten in DynamoDB bearbeiten +- Audit Log für Admin-Aktionen +- Frontend wird vom Backend direkt ausgeliefert +- Docker Compose Deployment auf dem Mailserver + +## Warum dynamische Domains berücksichtigt sind + +Die Domainliste ist **nicht hart codiert**. Beim Start und bei jedem manuellen Resync liest die App die DMS-Accounts aus: + +```bash +docker exec mailserver setup email list +``` + +Daraus werden Domains und Mailboxen abgeleitet und in Postgres gespiegelt. + +Wenn eine Domain später auf einen anderen Node umzieht, passiert Folgendes: + +- auf dem alten Node werden die lokalen Mailboxen nach einem Resync als `missing_on_node` markiert +- auf dem neuen Node wird dieselbe Domain beim Resync als `active` mit `current_node=node2` erkannt +- die App-Codebasis bleibt identisch; nur `.env` unterscheidet `NODE_NAME=node1` oder `NODE_NAME=node2` + +## Struktur + +```text +mailadmin-mvp/ + backend/ + src/ + server.ts Express App, API, static frontend + config.ts zentrale Konfiguration über Env Vars + db.ts Postgres Pool + Migration + Initial Admin + routes/ + auth.ts Login/Logout/Me + domains.ts Domains dynamisch vom DMS syncen/anzeigen + mailboxes.ts Mailbox CRUD, Usage, Rules, Blocklist + audit.ts Audit Log API + services/ + dms.ts DMS Docker Integration + Usage Scan + sync.ts Sync DMS -> Postgres + dynamodb.ts email-rules + email-blocked-senders + audit.ts Audit helper + utils/ + email.ts Email/domain/local-part helpers + shell.ts sicherer execFile wrapper + migrations/001_init.sql PostgreSQL Schema + Dockerfile Backend + Frontend Image + frontend/ + index.html Single Page App + app.js UI Logik ohne Build-Step + styles.css einfache Admin-Oberfläche + deploy/ + caddy/ Caddy-Hinweise + scripts/ Snippet-Hilfe für update-caddy-certs.sh + docker-compose.yml App + Postgres + .env.example Beispiel-Konfiguration +``` + +## Installation auf node1 + +```bash +cd /home/aknuth/git/email-amazon +unzip mailadmin-mvp.zip +cd mailadmin-mvp +cp .env.example .env +nano .env +``` + +Wichtige Werte in `.env`: + +```env +MAILADMIN_DB_PASSWORD=ein-sicheres-passwort +MAILADMIN_JWT_SECRET=sehr-langes-random-secret +MAILADMIN_ADMIN_EMAIL=admin@bayarea-cc.com +MAILADMIN_ADMIN_PASSWORD=StartPassword123! +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=... +``` + +Start: + +```bash +docker compose up -d --build +``` + +Logs: + +```bash +docker logs -f mailadmin +``` + +## Installation auf node2 + +Gleicher Code, andere `.env`: + +```env +NODE_NAME=node2 +NODE_HOSTNAME=node2.email-srvr.com +``` + +Die Domainliste wird auch dort aus dem lokalen DMS gelesen. + +## Caddy: mailadmin.{DOMAIN} + +Dein bestehendes `update-caddy-certs.sh` generiert bereits dynamisch Domain-Blöcke. Ergänze im Domain-Loop zusätzlich diesen Block: + +```caddy +# MailAdmin UI +mailadmin.DOMAIN_HERE { + encode gzip + reverse_proxy mailadmin:3000 +} +``` + +Im Script sieht das ungefähr so aus: + +```bash +OUTPUT="${OUTPUT}# MailAdmin UI\n" +OUTPUT="${OUTPUT}mailadmin.${domain} {\n" +OUTPUT="${OUTPUT} encode gzip\n" +OUTPUT="${OUTPUT} reverse_proxy mailadmin:3000\n" +OUTPUT="${OUTPUT}}\n\n" +``` + +Danach: + +```bash +cd /home/aknuth/git/email-amazon/caddy +./update-caddy-certs.sh +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +## Wichtige Sicherheitshinweise + +Diese MVP-Version mountet `/var/run/docker.sock`, damit der Backend-Container `docker exec mailserver ...` ausführen kann. Das ist praktisch, aber mächtig. Für die nächste Version wäre ein kleiner dedizierter Local-Agent mit begrenzten Operationen sicherer. + +Das Admin-Passwort wird nur beim ersten Start erstellt. Wenn du `MAILADMIN_ADMIN_PASSWORD` später änderst, wird der vorhandene User nicht überschrieben. Das ist Absicht. + +## API Kurzüberblick + +```text +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/me + +GET /api/domains?resync=true +POST /api/domains/resync + +GET /api/mailboxes?domain=example.com +POST /api/mailboxes +DELETE /api/mailboxes/:email +POST /api/mailboxes/:email/password +POST /api/mailboxes/usage/rescan + +GET /api/mailboxes/:email/rules +PUT /api/mailboxes/:email/rules + +GET /api/mailboxes/:email/blocklist +PUT /api/mailboxes/:email/blocklist + +GET /api/audit +``` + +## Nächste sinnvolle Ausbaustufen + +1. Domain-Detailseite mit DNS/SES/S3/SQS/Caddy Status +2. Admin-User Verwaltung im UI +3. Quotas pro Mailbox +4. Soft-Delete mit Retention statt hartem Löschen +5. Outbound Reporting über Postfix Logs und später SES Events +6. Node-Migration Workflow: Domain gezielt von node1 auf node2 markieren und prüfen diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5d40a36 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,25 @@ +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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..eb05942 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +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"] diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..8b518e1 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,63 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT UNIQUE NOT NULL, + hostname TEXT NOT NULL, + is_current BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS domains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + domain TEXT UNIQUE NOT NULL, + current_node TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ, + last_synced_at TIMESTAMPTZ, + notes TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS mailboxes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_address TEXT UNIQUE NOT NULL, + local_part TEXT NOT NULL, + domain TEXT NOT NULL REFERENCES domains(domain) ON DELETE CASCADE, + node_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + used_bytes BIGINT NOT NULL DEFAULT 0, + usage_scanned_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS admin_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'super_admin', + allowed_domains TEXT[] NOT NULL DEFAULT '{}', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_email TEXT, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + details JSONB NOT NULL DEFAULT '{}', + ip_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_domains_node ON domains(current_node); +CREATE INDEX IF NOT EXISTS idx_mailboxes_domain ON mailboxes(domain); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..64d30f5 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,33 @@ +{ + "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" + } +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..913fa52 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,24 @@ +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', +}; diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..6b21c96 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,32 @@ +import { Pool } from 'pg'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import bcrypt from 'bcryptjs'; +import { config } from './config.js'; + +export const pool = new Pool({ connectionString: config.databaseUrl }); + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export async function initDb(): Promise { + const migration = await readFile(join(__dirname, '../migrations/001_init.sql'), 'utf8').catch(async () => { + return readFile(join(process.cwd(), 'migrations/001_init.sql'), 'utf8'); + }); + await pool.query(migration); + + await pool.query( + `INSERT INTO nodes(name, hostname, is_current) + VALUES($1, $2, true) + ON CONFLICT (name) DO UPDATE SET hostname = EXCLUDED.hostname, is_current = true, updated_at = now()`, + [config.nodeName, config.nodeHostname], + ); + + const hash = await bcrypt.hash(config.adminPassword, 12); + await pool.query( + `INSERT INTO admin_users(email, password_hash, role) + VALUES($1, $2, 'super_admin') + ON CONFLICT (email) DO NOTHING`, + [config.adminEmail.toLowerCase(), hash], + ); +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..b0d3367 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,38 @@ +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()); +} diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts new file mode 100644 index 0000000..b10cf4b --- /dev/null +++ b/backend/src/routes/audit.ts @@ -0,0 +1,13 @@ +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); +}); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..be78a0b --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,40 @@ +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); +}); diff --git a/backend/src/routes/domains.ts b/backend/src/routes/domains.ts new file mode 100644 index 0000000..bf913b8 --- /dev/null +++ b/backend/src/routes/domains.ts @@ -0,0 +1,33 @@ +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); +}); diff --git a/backend/src/routes/mailboxes.ts b/backend/src/routes/mailboxes.ts new file mode 100644 index 0000000..6c29183 --- /dev/null +++ b/backend/src/routes/mailboxes.ts @@ -0,0 +1,113 @@ +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 }); +} + +mailboxesRouter.get('/', async (req, res) => { + const domain = String(req.query.domain ?? '').toLowerCase(); + if (domain) ensureDomain(req, domain); + 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 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.post('/usage/rescan', async (req, res) => { + const domain = String(req.body?.domain ?? '').toLowerCase(); + if (domain) ensureDomain(req, domain); + const params: unknown[] = [config.nodeName]; + let where = `WHERE node_name=$1 AND status='active'`; + if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; } + const rows = (await pool.query(`SELECT email_address, domain FROM mailboxes ${where}`, params)).rows; + let count = 0; + for (const row of rows) { + if (!canAccessDomain(req.user!, row.domain)) continue; + const bytes = await dms.getMailboxUsageBytes(row.email_address); + await pool.query(`UPDATE mailboxes SET used_bytes=$2, usage_scanned_at=now(), updated_at=now() WHERE email_address=$1`, [row.email_address, bytes]); + count++; + } + await audit(req.user!.email, 'mailbox.usage_rescan', 'domain', domain || '*', { count }, req.ip); + res.json({ count }); +}); + +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); +}); diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..d6efa6f --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,46 @@ +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.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 ?? 500; + console.error(err); + res.status(status).json({ error: err.message ?? 'Internal server error' }); +}); + +const publicDir = config.publicDir.startsWith('/') ? config.publicDir : resolve(__dirname, config.publicDir); +app.use(express.static(publicDir)); +app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html'))); + +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}`); +}); diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts new file mode 100644 index 0000000..57c2c3b --- /dev/null +++ b/backend/src/services/audit.ts @@ -0,0 +1,9 @@ +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], + ); +} diff --git a/backend/src/services/dms.ts b/backend/src/services/dms.ts new file mode 100644 index 0000000..61413df --- /dev/null +++ b/backend/src/services/dms.ts @@ -0,0 +1,83 @@ +import { existsSync } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; +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; +} + +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)); +} + +export class DmsService { + async listAccounts(): Promise { + const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']); + return parseAccounts(stdout); + } + + async addMailbox(email: string, password: string): Promise { + const normalized = normalizeEmail(email); + if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { + await run(config.manageMailUserScript, ['add', normalized, password]); + return; + } + await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'add', normalized, password]); + } + + async deleteMailbox(email: string): Promise { + const normalized = normalizeEmail(email); + if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { + await run(config.manageMailUserScript, ['del', normalized]); + return; + } + await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'del', normalized]); + } + + async updatePassword(email: string, password: string): Promise { + const normalized = normalizeEmail(email); + // docker-mailserver supports setup email update in current versions. + await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'update', normalized, password]); + } + + async syncSesDomain(domain: string): Promise { + if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) { + await run(config.manageMailUserScript, ['sync', domain.toLowerCase()]); + } + } + + async getMailboxUsageBytes(email: string): Promise { + const domain = domainFromEmail(email); + const local = localPartFromEmail(email); + const candidates = [ + join(config.mailDataPath, domain, local), + join(config.mailDataPath, domain, `${local}/`), + join(config.mailDataPath, domain, `${local}/Maildir`), + join(config.mailDataPath, email), + ]; + + for (const p of candidates) { + try { + await stat(p); + const { stdout } = await run('du', ['-sb', p], 60000); + const value = parseInt(stdout.trim().split(/\s+/)[0] ?? '0', 10); + return Number.isFinite(value) ? value : 0; + } catch { + // try next path + } + } + return 0; + } +} diff --git a/backend/src/services/dynamodb.ts b/backend/src/services/dynamodb.ts new file mode 100644 index 0000000..69ec7f2 --- /dev/null +++ b/backend/src/services/dynamodb.ts @@ -0,0 +1,60 @@ +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; + } +} diff --git a/backend/src/services/sync.ts b/backend/src/services/sync.ts new file mode 100644 index 0000000..a7db50b --- /dev/null +++ b/backend/src/services/sync.ts @@ -0,0 +1,65 @@ +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; + } + } +} diff --git a/backend/src/utils/email.ts b/backend/src/utils/email.ts new file mode 100644 index 0000000..bbdce1a --- /dev/null +++ b/backend/src/utils/email.ts @@ -0,0 +1,29 @@ +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]}`; +} diff --git a/backend/src/utils/shell.ts b/backend/src/utils/shell.ts new file mode 100644 index 0000000..ed67053 --- /dev/null +++ b/backend/src/utils/shell.ts @@ -0,0 +1,21 @@ +import { execFile } from 'node:child_process'; + +export interface ShellResult { + stdout: string; + stderr: string; +} + +export function run(cmd: string, args: string[], timeoutMs = 120000): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + const err = new Error(`Command failed: ${cmd} ${args.join(' ')}\n${stderr || error.message}`); + (err as any).stdout = stdout; + (err as any).stderr = stderr; + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + }); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..9b8ff39 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/deploy/caddy/mailadmin-snippet.caddy b/deploy/caddy/mailadmin-snippet.caddy new file mode 100644 index 0000000..99a7a4b --- /dev/null +++ b/deploy/caddy/mailadmin-snippet.caddy @@ -0,0 +1,7 @@ +# Add this block in the per-domain loop of caddy/update-caddy-certs.sh. +# It assumes the mailadmin container is on the same external Docker network. + +mailadmin.{$DOMAIN_NAME} { + encode gzip + reverse_proxy mailadmin:3000 +} diff --git a/deploy/scripts/update-caddy-certs-mailadmin.patch.sh b/deploy/scripts/update-caddy-certs-mailadmin.patch.sh new file mode 100755 index 0000000..0f1de78 --- /dev/null +++ b/deploy/scripts/update-caddy-certs-mailadmin.patch.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Helper snippet for your existing caddy/update-caddy-certs.sh. +# Inside the loop that generates blocks for each $domain, add: + +cat <<'CADDY' +# MailAdmin UI +mailadmin.$domain { + encode gzip + reverse_proxy mailadmin:3000 +} + +CADDY diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..90a9c9b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + mailadmin-db: + image: postgres:16 + container_name: mailadmin-db + restart: unless-stopped + environment: + POSTGRES_DB: mailadmin + POSTGRES_USER: mailadmin + POSTGRES_PASSWORD: ${MAILADMIN_DB_PASSWORD:-change-me} + volumes: + - ./data/postgres:/var/lib/postgresql/data + networks: + - mail_network + + mailadmin: + build: + context: . + dockerfile: backend/Dockerfile + container_name: mailadmin + restart: unless-stopped + depends_on: + - mailadmin-db + 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: + - mail_network + +networks: + mail_network: + external: true diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..0b7e6b2 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,162 @@ +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 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(); +} + +async function loadMailboxes() { + state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}`); +} + +async function loadAudit() { + state.audit = await api('/api/audit'); + renderAuditModal(); +} + +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.
+
+
+ + + + ${state.mailboxes.map(m => ` + + + + + + + `).join('') || ''} + +
EmailStatusUsageUpdatedActions
${esc(m.email_address)}
${esc(m.node_name)}
${esc(m.status)}${bytes(m.used_bytes)}
${m.usage_scanned_at ? new Date(m.usage_scanned_at).toLocaleString() : 'not scanned'}
${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.getElementById('usageBtn').onclick = guard(async () => { await api('/api/mailboxes/usage/rescan', { method:'POST', body: JSON.stringify({ domain: state.selectedDomain }) }); await loadMailboxes(); render(); }); + document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(); 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 d = modal(`

New mailbox

`); + d.querySelector('#createForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api('/api/mailboxes', { method:'POST', body: JSON.stringify({ email:f.get('email'), password:f.get('password') }) }); d.remove(); await loadDomains(false); render(); }); +} + +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(); 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(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b8c5ad4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + MailAdmin + + + +
+ + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..a2a3381 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,35 @@ +:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; } +* { 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; } +@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } }