initial commit
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -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=
|
||||
191
README.md
Normal file
191
README.md
Normal file
@@ -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
|
||||
25
backend/.env.example
Normal file
25
backend/.env.example
Normal file
@@ -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
|
||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
@@ -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"]
|
||||
63
backend/migrations/001_init.sql
Normal file
63
backend/migrations/001_init.sql
Normal file
@@ -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);
|
||||
33
backend/package.json
Normal file
33
backend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
24
backend/src/config.ts
Normal file
24
backend/src/config.ts
Normal file
@@ -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',
|
||||
};
|
||||
32
backend/src/db.ts
Normal file
32
backend/src/db.ts
Normal file
@@ -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<void> {
|
||||
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],
|
||||
);
|
||||
}
|
||||
38
backend/src/middleware/auth.ts
Normal file
38
backend/src/middleware/auth.ts
Normal file
@@ -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());
|
||||
}
|
||||
13
backend/src/routes/audit.ts
Normal file
13
backend/src/routes/audit.ts
Normal file
@@ -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);
|
||||
});
|
||||
40
backend/src/routes/auth.ts
Normal file
40
backend/src/routes/auth.ts
Normal file
@@ -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);
|
||||
});
|
||||
33
backend/src/routes/domains.ts
Normal file
33
backend/src/routes/domains.ts
Normal file
@@ -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);
|
||||
});
|
||||
113
backend/src/routes/mailboxes.ts
Normal file
113
backend/src/routes/mailboxes.ts
Normal file
@@ -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);
|
||||
});
|
||||
46
backend/src/server.ts
Normal file
46
backend/src/server.ts
Normal file
@@ -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}`);
|
||||
});
|
||||
9
backend/src/services/audit.ts
Normal file
9
backend/src/services/audit.ts
Normal file
@@ -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<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],
|
||||
);
|
||||
}
|
||||
83
backend/src/services/dms.ts
Normal file
83
backend/src/services/dms.ts
Normal file
@@ -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<DmsAccount[]> {
|
||||
const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']);
|
||||
return parseAccounts(stdout);
|
||||
}
|
||||
|
||||
async addMailbox(email: string, password: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const normalized = normalizeEmail(email);
|
||||
// docker-mailserver supports setup email update <email> <password> in current versions.
|
||||
await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'update', normalized, password]);
|
||||
}
|
||||
|
||||
async syncSesDomain(domain: string): Promise<void> {
|
||||
if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) {
|
||||
await run(config.manageMailUserScript, ['sync', domain.toLowerCase()]);
|
||||
}
|
||||
}
|
||||
|
||||
async getMailboxUsageBytes(email: string): Promise<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
60
backend/src/services/dynamodb.ts
Normal file
60
backend/src/services/dynamodb.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
}
|
||||
65
backend/src/services/sync.ts
Normal file
65
backend/src/services/sync.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
backend/src/utils/email.ts
Normal file
29
backend/src/utils/email.ts
Normal file
@@ -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]}`;
|
||||
}
|
||||
21
backend/src/utils/shell.ts
Normal file
21
backend/src/utils/shell.ts
Normal file
@@ -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<ShellResult> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
13
backend/tsconfig.json
Normal file
13
backend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
7
deploy/caddy/mailadmin-snippet.caddy
Normal file
7
deploy/caddy/mailadmin-snippet.caddy
Normal file
@@ -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
|
||||
}
|
||||
12
deploy/scripts/update-caddy-certs-mailadmin.patch.sh
Executable file
12
deploy/scripts/update-caddy-certs-mailadmin.patch.sh
Executable file
@@ -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
|
||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
@@ -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
|
||||
162
frontend/app.js
Normal file
162
frontend/app.js
Normal file
@@ -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 = `
|
||||
<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.</div></div>
|
||||
<div class="actions"><button id="newMailboxBtn">New mailbox</button><button class="secondary" id="usageBtn">Scan usage</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>${bytes(m.used_bytes)}<div class="muted">${m.usage_scanned_at ? new Date(m.usage_scanned_at).toLocaleString() : 'not scanned'}</div></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.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 = `<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 d = modal(`<h2>New mailbox</h2><form id="createForm" class="form-grid"><label>Email<input name="email" type="email" value="@${esc(state.selectedDomain || '')}" required></label><label>Password<input name="password" type="password" minlength="8" required></label><div><button>Create</button></div></form>`);
|
||||
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(`<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(); 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();
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
35
frontend/styles.css
Normal file
35
frontend/styles.css
Normal file
@@ -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; } }
|
||||
Reference in New Issue
Block a user