diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b5c06a8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+frontend.old
diff --git a/backend/Dockerfile b/backend/Dockerfile
index eb05942..4412b2c 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,17 +1,45 @@
-FROM node:22-bookworm AS build
+# syntax=docker/dockerfile:1
+# ============================================================
+# Stage 1: Build the React frontend (Vite)
+# ============================================================
+FROM node:22-bookworm AS frontend-build
+WORKDIR /app/frontend
+COPY frontend/package*.json ./
+RUN npm install
+COPY frontend ./
+RUN npm run build
+# Output: /app/frontend/dist
+
+# ============================================================
+# Stage 2: Build the TypeScript backend
+# ============================================================
+FROM node:22-bookworm AS backend-build
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install
COPY backend ./
RUN npm run build
+# Output: /app/backend/dist + node_modules
+# ============================================================
+# Stage 3: Slim runtime image
+# ============================================================
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
+
+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/*
+
+# Backend runtime
+COPY --from=backend-build /app/backend/node_modules ./backend/node_modules
+COPY --from=backend-build /app/backend/dist ./backend/dist
+COPY backend/migrations ./backend/migrations
+
+# Frontend bundle (served by express.static via PUBLIC_DIR)
+COPY --from=frontend-build /app/frontend/dist ./frontend
+
WORKDIR /app/backend
ENV NODE_ENV=production PUBLIC_DIR=/app/frontend
CMD ["node", "dist/server.js"]
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..b08d571
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+dist/
+build/
+.env
+.env.local
+.env.production
+*.log
+.DS_Store
+.vscode/
+.idea/
diff --git a/frontend/app.js b/frontend/app.js
deleted file mode 100644
index 7732ab0..0000000
--- a/frontend/app.js
+++ /dev/null
@@ -1,302 +0,0 @@
-const app = document.getElementById('app');
-const state = { user: null, domains: [], selectedDomain: null, mailboxes: [], audit: [], message: '', error: '' };
-
-async function api(path, options = {}) {
- const res = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options });
- if (!res.ok) {
- const body = await res.json().catch(() => ({}));
- throw new Error(body.error || `HTTP ${res.status}`);
- }
- return res.json();
-}
-
-function bytes(n) {
- n = Number(n || 0);
- if (n <= 0) return '0 B';
- const u = ['B','KB','MB','GB','TB']; let i = 0;
- while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
- return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
-}
-
-function percent(n) {
- if (n === null || n === undefined || Number.isNaN(Number(n))) return null;
- const value = Number(n);
- if (value < 1 && value > 0) return value.toFixed(1);
- return value.toFixed(0);
-}
-
-function usageInfo(m) {
- const used = Number(m.used_bytes || 0);
- const limit = m.quota_bytes === null || m.quota_bytes === undefined ? null : Number(m.quota_bytes);
- const calculated = limit && limit > 0 ? (used / limit) * 100 : null;
- const rawPct = m.quota_percent === null || m.quota_percent === undefined ? calculated : Number(m.quota_percent);
- const pct = rawPct === null || Number.isNaN(rawPct) ? null : Math.max(0, Math.min(100, rawPct));
-
- return {
- used,
- limit,
- pct,
- label: limit ? `${bytes(used)} / ${bytes(limit)} (${percent(pct)}%)` : `${bytes(used)} / unlimited`,
- };
-}
-
-function esc(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
-
-async function init() {
- try { state.user = await api('/api/auth/me'); await loadDomains(true); }
- catch { state.user = null; }
- render();
-}
-
-async function loadDomains(resync = false) {
- state.domains = await api(`/api/domains${resync ? '?resync=true' : ''}`);
- if (!state.selectedDomain && state.domains.length) state.selectedDomain = state.domains[0].domain;
- if (state.selectedDomain) await loadMailboxes(true);
-}
-
-async function loadMailboxes(refreshQuota = false) {
- if (!state.selectedDomain) {
- state.mailboxes = [];
- return;
- }
- state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}${refreshQuota ? '&refreshQuota=true' : ''}`);
-}
-
-async function loadAudit() {
- state.audit = await api('/api/audit');
- renderAuditModal();
-}
-
-function renderUsage(m) {
- const usage = usageInfo(m);
- const barWidth = usage.pct === null ? 0 : usage.pct;
- return `
-
-
Disk Usage: ${esc(usage.label)}
-
-
- ${m.usage_scanned_at ? `quota checked ${new Date(m.usage_scanned_at).toLocaleString()}` : 'quota not checked yet'}
- ${m.message_count !== null && m.message_count !== undefined ? ` · ${Number(m.message_count)} messages` : ''}
-
-
`;
-}
-
-function render() {
- if (!state.user) return renderLogin();
- app.innerHTML = `
-
-
- ${state.error ? `
${esc(state.error)}
` : ''}
- ${state.message ? `
${esc(state.message)}
` : ''}
-
-
- Domains on this node
- Domains are discovered dynamically from DMS accounts.
-
- ${state.domains.map(d => `
-
- ${esc(d.domain)}
- ${d.active_mailboxes || 0} inboxes · ${bytes(d.used_bytes)}
- ${esc(d.current_node)} ${esc(d.status)}
-
`).join('') || '
No domains found yet.
'}
-
-
-
-
-
${esc(state.selectedDomain || 'Mailboxes')}
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
-
-
-
- | Email | Status | Usage | Updated | Actions |
-
- ${state.mailboxes.map(m => `
-
- | ${esc(m.email_address)} ${esc(m.node_name)} |
- ${esc(m.status)} |
- ${renderUsage(m)} |
- ${new Date(m.updated_at).toLocaleString()} |
-
-
-
-
-
- |
-
`).join('') || '| No mailboxes for this domain. |
'}
-
-
-
-
-
`;
-
- document.getElementById('logoutBtn').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); state.user = null; render(); };
- document.getElementById('resyncBtn').onclick = guard(async () => { await api('/api/domains/resync', { method:'POST' }); await loadDomains(false); state.message = 'DMS sync completed.'; render(); });
- document.getElementById('auditBtn').onclick = guard(loadAudit);
- document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal;
- document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(true); render(); }));
- document.querySelectorAll('[data-delete]').forEach(el => el.onclick = () => renderDeleteModal(el.dataset.delete));
- document.querySelectorAll('[data-password]').forEach(el => el.onclick = () => renderPasswordModal(el.dataset.password));
- document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules));
- document.querySelectorAll('[data-blocks]').forEach(el => el.onclick = () => renderBlocklistModal(el.dataset.blocks));
-}
-
-function renderLogin() {
- app.innerHTML = `MailAdmin Login
${state.error ? `
${esc(state.error)}
` : ''}
`;
- document.getElementById('loginForm').onsubmit = guard(async e => {
- e.preventDefault(); const f = new FormData(e.target);
- state.user = await api('/api/auth/login', { method:'POST', body: JSON.stringify({ email: f.get('email'), password: f.get('password') }) });
- await loadDomains(true); render();
- });
-}
-
-function modal(html) {
- const div = document.createElement('div');
- div.className = 'modal-backdrop';
- div.innerHTML = ``;
- document.body.appendChild(div);
- div.querySelector('[data-close]').onclick = () => div.remove();
- div.onclick = e => { if (e.target === div) div.remove(); };
- return div;
-}
-
-function renderCreateMailboxModal() {
- const domain = state.selectedDomain || '';
-
- const d = modal(`
- New mailbox
-
-
- `);
-
- const emailInput = d.querySelector('#createEmail');
- const passwordInput = d.querySelector('#createPassword');
- const submitButton = d.querySelector('#createMailboxSubmit');
-
- emailInput.focus();
-
- // Cursor before the @domain, so you can directly type "test".
- try {
- emailInput.setSelectionRange(0, 0);
- } catch {
- // Some browsers do not allow selection on email inputs.
- }
-
- const createMailbox = guard(async () => {
- const email = String(emailInput.value || '').trim().toLowerCase();
- const password = String(passwordInput.value || '');
-
- if (!email || !email.includes('@')) {
- throw new Error('Please enter a valid email address.');
- }
-
- if (!email.endsWith(`@${domain}`)) {
- throw new Error(`Mailbox must belong to ${domain}.`);
- }
-
- if (password.length < 8) {
- throw new Error('Password must have at least 8 characters.');
- }
-
- submitButton.disabled = true;
- submitButton.textContent = 'Creating...';
-
- await api('/api/mailboxes', {
- method: 'POST',
- body: JSON.stringify({
- email,
- password,
- }),
- });
-
- d.remove();
-
- state.message = `Mailbox created: ${email}`;
-
- await loadDomains(false);
- await loadMailboxes(true);
-
- render();
- });
-
- submitButton.onclick = createMailbox;
-
- passwordInput.addEventListener('keydown', (event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- createMailbox();
- }
- });
-
- emailInput.addEventListener('keydown', (event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- passwordInput.focus();
- }
- });
-}
-
-function renderDeleteModal(email) {
- const d = modal(`Delete mailbox
Delete ${esc(email)} from DMS?
`);
- d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(true); render(); });
-}
-
-function renderPasswordModal(email) {
- const d = modal(`Reset password
`);
- d.querySelector('#pwForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/password`, { method:'POST', body: JSON.stringify({ password:f.get('password') }) }); d.remove(); state.message = `Password updated for ${email}.`; render(); });
-}
-
-async function renderRulesModal(email) {
- const rules = await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`);
- const d = modal(`Rules for ${esc(email)}
`);
- d.querySelector('#rulesForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`, { method:'PUT', body: JSON.stringify({ ooo_active: !!f.get('ooo_active'), ooo_message:f.get('ooo_message'), forwards:String(f.get('forwards')||'').split(/\n|,/).map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
-}
-
-async function renderBlocklistModal(email) {
- const block = await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`);
- const d = modal(`Blocklist for ${esc(email)}
`);
- d.querySelector('#blockForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { method:'PUT', body: JSON.stringify({ blocked_patterns:String(f.get('blocked_patterns')||'').split('\n').map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
-}
-
-function renderAuditModal() {
- modal(`Audit Log
| Time | Actor | Action | Target |
${state.audit.map(a => `| ${new Date(a.created_at).toLocaleString()} | ${esc(a.actor_email)} | ${esc(a.action)} | ${esc(a.target_id)} |
`).join('')}
`);
-}
-
-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();
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index b8c5ad4..7076b49 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,13 +1,15 @@
-
+
-
-
-
- MailAdmin
-
-
-
-
-
-
+
+
+
+
+
+
+ MailAdmin
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..fe594d5
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "mailadmin-ui",
+ "version": "0.2.0",
+ "description": "MailAdmin React UI",
+ "private": true,
+ "scripts": {
+ "dev": "vite --host --port 3009",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "axios": "^1.6.2",
+ "react-icons": "^4.12.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.43",
+ "@types/react-dom": "^18.2.17",
+ "@vitejs/plugin-react": "^4.2.1",
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.32",
+ "tailwindcss": "^3.4.0",
+ "vite": "^5.0.8"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..8e6472a
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,384 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
+} from 'react-icons/fi';
+
+import Login from './components/Login';
+import Toast from './components/Toast';
+import LoadingOverlay from './components/LoadingOverlay';
+import UsageBar, { formatBytes } from './components/UsageBar';
+import MailboxSettingsModal from './components/MailboxSettingsModal';
+import NewMailboxModal from './components/NewMailboxModal';
+import PasswordResetModal from './components/PasswordResetModal';
+import ConfirmDialog from './components/ConfirmDialog';
+import AuditLogModal from './components/AuditLogModal';
+
+import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
+
+function App() {
+ const [user, setUser] = useState(null);
+ const [bootChecked, setBootChecked] = useState(false);
+
+ const [domains, setDomains] = useState([]);
+ const [selectedDomain, setSelectedDomain] = useState(null);
+ const [mailboxes, setMailboxes] = useState([]);
+
+ const [busyMessage, setBusyMessage] = useState(''); // global blocking spinner
+ const [toast, setToast] = useState(null);
+
+ const [settingsTarget, setSettingsTarget] = useState(null); // { email, tab }
+ const [pwTarget, setPwTarget] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [showNew, setShowNew] = useState(false);
+ const [showAudit, setShowAudit] = useState(false);
+
+ const showToast = useCallback((message, type = 'success') => {
+ setToast({ message, type });
+ }, []);
+
+ // ---- data loading helpers ----
+ const loadDomains = useCallback(async (resync = false) => {
+ const list = await domainsAPI.list(resync);
+ setDomains(list);
+ return list;
+ }, []);
+
+ const loadMailboxes = useCallback(async (domain, refreshQuota = false) => {
+ if (!domain) { setMailboxes([]); return; }
+ const list = await mailboxesAPI.list(domain, refreshQuota);
+ setMailboxes(list);
+ }, []);
+
+ // ---- initial boot ----
+ useEffect(() => {
+ (async () => {
+ try {
+ const me = await authAPI.me();
+ setUser(me);
+ } catch {
+ setUser(null);
+ } finally {
+ setBootChecked(true);
+ }
+ })();
+ }, []);
+
+ // After login (or on first authenticated render): load domains.
+ useEffect(() => {
+ if (!user) return;
+ (async () => {
+ setBusyMessage('Loading domains...');
+ try {
+ const list = await loadDomains(true);
+ const first = list[0]?.domain || null;
+ setSelectedDomain(first);
+ if (first) {
+ setBusyMessage('Refreshing quotas...');
+ await loadMailboxes(first, true);
+ }
+ } catch (err) {
+ showToast(`Failed to load: ${err.message}`, 'error');
+ } finally {
+ setBusyMessage('');
+ }
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user]);
+
+ // Switch domain -> reload mailboxes (with quota refresh, like the original).
+ const selectDomain = async (domain) => {
+ if (domain === selectedDomain) return;
+ setSelectedDomain(domain);
+ setBusyMessage('Loading mailboxes...');
+ try {
+ await loadMailboxes(domain, true);
+ } catch (err) {
+ showToast(`Failed to load mailboxes: ${err.message}`, 'error');
+ } finally {
+ setBusyMessage('');
+ }
+ };
+
+ const handleResync = async () => {
+ setBusyMessage('Re-syncing from DMS...');
+ try {
+ await domainsAPI.resync();
+ const list = await loadDomains(false);
+ // If selected domain disappeared, fall back to first.
+ if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
+ const first = list[0]?.domain || null;
+ setSelectedDomain(first);
+ if (first) await loadMailboxes(first, false);
+ } else if (selectedDomain) {
+ await loadMailboxes(selectedDomain, false);
+ }
+ showToast('DMS sync complete', 'success');
+ } catch (err) {
+ showToast(`Sync failed: ${err.message}`, 'error');
+ } finally {
+ setBusyMessage('');
+ }
+ };
+
+ const handleLogout = async () => {
+ try { await authAPI.logout(); } catch { /* ignore */ }
+ setUser(null);
+ setDomains([]);
+ setMailboxes([]);
+ setSelectedDomain(null);
+ };
+
+ const handleDelete = async () => {
+ if (!deleteTarget) return;
+ try {
+ await mailboxesAPI.remove(deleteTarget);
+ showToast(`Deleted ${deleteTarget}`, 'success');
+ setDeleteTarget(null);
+ await loadMailboxes(selectedDomain, false);
+ await loadDomains(false);
+ } catch (err) {
+ showToast(`Delete failed: ${err.message}`, 'error');
+ }
+ };
+
+ const handleMailboxCreated = async () => {
+ setBusyMessage('Refreshing...');
+ try {
+ await loadDomains(false);
+ await loadMailboxes(selectedDomain, true);
+ } finally {
+ setBusyMessage('');
+ }
+ };
+
+ // ---- render ----
+ if (!bootChecked) {
+ return Loading...
;
+ }
+
+ if (!user) {
+ return (
+ <>
+
+ {toast && setToast(null)} />}
+ >
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Main */}
+
+
+ {/* Domains */}
+
+ Domains on this node
+
+ Domains are discovered dynamically from DMS accounts.
+
+
+ {domains.length === 0 ? (
+ No domains found yet.
+ ) : (
+
+ {domains.map((d) => {
+ const active = d.domain === selectedDomain;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {/* Mailboxes */}
+
+
+
+
+ {selectedDomain || 'Mailboxes'}
+
+
+ Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
+
+
+
+
+
+ {mailboxes.length === 0 ? (
+
+
+
No mailboxes for this domain
+
+ {selectedDomain ? 'Click "New mailbox" to create the first one.' : 'Select a domain first.'}
+
+
+ ) : (
+
+
+
+
+ | Email |
+ Status |
+ Usage |
+ Updated |
+ Actions |
+
+
+
+ {mailboxes.map((m) => (
+
+ |
+ {m.email_address}
+ {m.node_name}
+ |
+
+
+ {m.status}
+
+ |
+ |
+
+ {new Date(m.updated_at).toLocaleString()}
+ |
+ {/* Actions: single row, no wrapping. Punkt 3 deiner Liste. */}
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ {/* Modals */}
+
setSettingsTarget(null)}
+ onToast={showToast}
+ />
+ setShowNew(false)}
+ onCreated={handleMailboxCreated}
+ onToast={showToast}
+ />
+ setPwTarget(null)}
+ onToast={showToast}
+ />
+ setDeleteTarget(null)}
+ />
+ setShowAudit(false)}
+ onToast={showToast}
+ />
+
+ {/* Global blocking overlay (Punkt 2 deiner Liste) */}
+ {busyMessage && }
+
+ {/* Toast */}
+ {toast && setToast(null)} />}
+
+ );
+}
+
+export default App;
diff --git a/frontend/src/components/AuditLogModal.jsx b/frontend/src/components/AuditLogModal.jsx
new file mode 100644
index 0000000..41f420c
--- /dev/null
+++ b/frontend/src/components/AuditLogModal.jsx
@@ -0,0 +1,65 @@
+import React, { useEffect, useState } from 'react';
+import Modal from './Modal';
+import LoadingOverlay from './LoadingOverlay';
+import { auditAPI } from '../services/api';
+
+const AuditLogModal = ({ open, onClose, onToast }) => {
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ try {
+ const data = await auditAPI.list();
+ if (!cancelled) setRows(data || []);
+ } catch (err) {
+ if (!cancelled) onToast?.(`Failed to load audit log: ${err.message}`, 'error');
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [open, onToast]);
+
+ return (
+
+
+ {loading &&
}
+ {!loading && rows.length === 0 && (
+
No audit entries yet.
+ )}
+ {!loading && rows.length > 0 && (
+
+
+
+
+ | Time |
+ Actor |
+ Action |
+ Target |
+
+
+
+ {rows.map((a) => (
+
+ |
+ {new Date(a.created_at).toLocaleString()}
+ |
+ {a.actor_email} |
+ {a.action} |
+ {a.target_id} |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+};
+
+export default AuditLogModal;
diff --git a/frontend/src/components/BlockedSenders.jsx b/frontend/src/components/BlockedSenders.jsx
new file mode 100644
index 0000000..0795c1a
--- /dev/null
+++ b/frontend/src/components/BlockedSenders.jsx
@@ -0,0 +1,158 @@
+import React, { useState } from 'react';
+import { FiSlash, FiPlus, FiTrash2, FiShield } from 'react-icons/fi';
+
+const BlockedSenders = ({ blocklist, onSave }) => {
+ const [patterns, setPatterns] = useState(blocklist?.blocked_patterns || []);
+ const [newPattern, setNewPattern] = useState('');
+ const [inputMessage, setInputMessage] = useState({ text: '', kind: '' });
+ const [isSaving, setIsSaving] = useState(false);
+
+ const validatePattern = (p) => p.length >= 3;
+
+ const handleAdd = () => {
+ if (!newPattern.trim()) {
+ setInputMessage({ text: 'Please enter a pattern', kind: 'error' });
+ return;
+ }
+
+ const candidates = newPattern
+ .split(',')
+ .map((p) => p.trim())
+ .filter(Boolean);
+
+ const accepted = [];
+ let invalid = 0;
+ let dupes = 0;
+
+ for (const p of candidates) {
+ if (!validatePattern(p)) { invalid++; continue; }
+ if (patterns.includes(p) || accepted.includes(p)) { dupes++; continue; }
+ accepted.push(p);
+ }
+
+ if (accepted.length === 0) {
+ if (invalid > 0 && candidates.length === invalid) {
+ setInputMessage({ text: 'All entered patterns were too short (min. 3 chars).', kind: 'error' });
+ } else if (dupes > 0) {
+ setInputMessage({ text: 'All entered patterns are already in the list.', kind: 'error' });
+ }
+ return;
+ }
+
+ setPatterns([...patterns, ...accepted]);
+ setNewPattern('');
+
+ if (invalid > 0 || dupes > 0) {
+ setInputMessage({
+ text: `Added ${accepted.length} patterns. (${invalid} invalid, ${dupes} duplicates skipped)`,
+ kind: 'success',
+ });
+ } else {
+ setInputMessage({ text: '', kind: '' });
+ }
+ };
+
+ const handleRemove = (p) => setPatterns(patterns.filter((x) => x !== p));
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ try {
+ await onSave(patterns);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
{
+ setNewPattern(e.target.value);
+ if (inputMessage.kind === 'error') setInputMessage({ text: '', kind: '' });
+ }}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
+ placeholder="spam@*.com, *@badsite.org (comma separated)"
+ className={`input-field ${inputMessage.kind === 'error' ? 'border-red-500 focus:ring-red-500' : ''}`}
+ />
+ {inputMessage.text && (
+
+ {inputMessage.text}
+
+ )}
+
+
+
+
+ Paste a comma-separated list to add multiple entries at once. Supports wildcards (*).
+
+
+
+
+
+
+ {patterns.length > 0 && (
+
+ )}
+
+
+ {patterns.length === 0 ? (
+
+
+
No blocked senders configured
+
Add a pattern above to block incoming emails
+
+ ) : (
+
+ {patterns.map((pattern, idx) => (
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default BlockedSenders;
diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx
new file mode 100644
index 0000000..d9ec9b3
--- /dev/null
+++ b/frontend/src/components/ConfirmDialog.jsx
@@ -0,0 +1,37 @@
+import React, { useState } from 'react';
+import { FiAlertTriangle } from 'react-icons/fi';
+import Modal from './Modal';
+
+const ConfirmDialog = ({ open, title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onClose }) => {
+ const [busy, setBusy] = useState(false);
+ const handleConfirm = async () => {
+ setBusy(true);
+ try { await onConfirm(); }
+ finally { setBusy(false); }
+ };
+
+ return (
+
+
+ {danger && (
+
+
+
+ )}
+
{message}
+
+
+
+
+
+
+ );
+};
+
+export default ConfirmDialog;
diff --git a/frontend/src/components/Forwarding.jsx b/frontend/src/components/Forwarding.jsx
new file mode 100644
index 0000000..8126c1c
--- /dev/null
+++ b/frontend/src/components/Forwarding.jsx
@@ -0,0 +1,118 @@
+import React, { useState } from 'react';
+import { FiMail, FiPlus, FiTrash2, FiCheck } from 'react-icons/fi';
+
+/**
+ * Forwarding tab for one mailbox.
+ * Receives the current rule object and onSave callback that updates the
+ * mailadmin /rules endpoint (which expects { ooo_active, ooo_message, forwards }).
+ * onSave is called with just the updated `forwards` field; the parent
+ * merges it with the existing rule before persisting.
+ */
+const Forwarding = ({ rule, onSave }) => {
+ const [forwards, setForwards] = useState(rule?.forwards || []);
+ const [newEmail, setNewEmail] = useState('');
+ const [emailError, setEmailError] = useState('');
+ const [isSaving, setIsSaving] = useState(false);
+
+ const validateEmail = (email) =>
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+
+ const handleAdd = () => {
+ const trimmed = newEmail.trim().toLowerCase();
+ if (!trimmed) { setEmailError('Email address is required'); return; }
+ if (!validateEmail(trimmed)) { setEmailError('Please enter a valid email address'); return; }
+ if (forwards.includes(trimmed)) { setEmailError('Already in the list'); return; }
+ setForwards([...forwards, trimmed]);
+ setNewEmail('');
+ setEmailError('');
+ };
+
+ const handleRemove = (email) => setForwards(forwards.filter((e) => e !== email));
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ try {
+ await onSave({ forwards });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
{ setNewEmail(e.target.value); setEmailError(''); }}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
+ placeholder="email@example.com"
+ className={`input-field ${emailError ? 'border-red-500 focus:ring-red-500' : ''}`}
+ />
+ {emailError &&
{emailError}
}
+
+
+
+
+ All emails sent to this mailbox will also be delivered to the addresses below.
+
+
+
+
+
+
+
+
+ {forwards.length === 0 ? (
+
+
+
No forward addresses configured
+
Add an email address above to get started
+
+ ) : (
+
+ {forwards.map((email, idx) => (
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default Forwarding;
diff --git a/frontend/src/components/LoadingOverlay.jsx b/frontend/src/components/LoadingOverlay.jsx
new file mode 100644
index 0000000..726053e
--- /dev/null
+++ b/frontend/src/components/LoadingOverlay.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { FiLoader } from 'react-icons/fi';
+
+/**
+ * Block UI overlay. Pass `fullscreen` for app-level blocking,
+ * otherwise it covers the nearest positioned (relative) parent.
+ */
+const LoadingOverlay = ({ message = 'Loading...', fullscreen = false }) => {
+ const positioning = fullscreen
+ ? 'fixed inset-0 z-40'
+ : 'absolute inset-0 z-20 rounded-xl';
+
+ return (
+
+ );
+};
+
+export default LoadingOverlay;
diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx
new file mode 100644
index 0000000..8fc0411
--- /dev/null
+++ b/frontend/src/components/Login.jsx
@@ -0,0 +1,73 @@
+import React, { useState } from 'react';
+import { FiMail, FiLock } from 'react-icons/fi';
+import { authAPI } from '../services/api';
+
+const Login = ({ onLogin }) => {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e) => {
+ e.preventDefault();
+ setBusy(true); setError('');
+ try {
+ const me = await authAPI.login(email.trim().toLowerCase(), password);
+ onLogin(me);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
MailAdmin
+
Sign in to manage your mail server
+
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/frontend/src/components/MailboxSettingsModal.jsx b/frontend/src/components/MailboxSettingsModal.jsx
new file mode 100644
index 0000000..24cf3b9
--- /dev/null
+++ b/frontend/src/components/MailboxSettingsModal.jsx
@@ -0,0 +1,130 @@
+import React, { useEffect, useState } from 'react';
+import { FiCornerUpRight, FiCalendar, FiSlash } from 'react-icons/fi';
+import Modal from './Modal';
+import LoadingOverlay from './LoadingOverlay';
+import Forwarding from './Forwarding';
+import OutOfOffice from './OutOfOffice';
+import BlockedSenders from './BlockedSenders';
+import { mailboxesAPI } from '../services/api';
+
+const TABS = [
+ { id: 'fwd', label: 'Forwarding', icon: FiCornerUpRight },
+ { id: 'ooo', label: 'Out of Office', icon: FiCalendar },
+ { id: 'block', label: 'Blocklist', icon: FiSlash },
+];
+
+const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => {
+ const [activeTab, setActiveTab] = useState(initialTab);
+ const [rule, setRule] = useState(null);
+ const [blocklist, setBlocklist] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => { setActiveTab(initialTab); }, [initialTab, email]);
+
+ // Load both /rules and /blocklist in parallel when the modal opens.
+ useEffect(() => {
+ if (!open || !email) return;
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ try {
+ const [r, b] = await Promise.all([
+ mailboxesAPI.getRules(email).catch(() => ({
+ email_address: email, ooo_active: false, ooo_message: '', forwards: [],
+ })),
+ mailboxesAPI.getBlocklist(email).catch(() => ({
+ email_address: email, blocked_patterns: [],
+ })),
+ ]);
+ if (cancelled) return;
+ setRule(r || { email_address: email, ooo_active: false, ooo_message: '', forwards: [] });
+ setBlocklist(b || { email_address: email, blocked_patterns: [] });
+ } catch (err) {
+ if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [open, email, onToast]);
+
+ // Merge updates with existing rule and persist.
+ const saveRule = async (updates) => {
+ const merged = {
+ ooo_active: rule?.ooo_active ?? false,
+ ooo_message: rule?.ooo_message ?? '',
+ forwards: rule?.forwards ?? [],
+ ...updates,
+ };
+ try {
+ const saved = await mailboxesAPI.putRules(email, merged);
+ setRule({ email_address: email, ...merged, ...saved });
+ onToast?.('Rule saved', 'success');
+ } catch (err) {
+ onToast?.(`Failed to save: ${err.message}`, 'error');
+ throw err;
+ }
+ };
+
+ const saveBlocklist = async (patterns) => {
+ try {
+ const saved = await mailboxesAPI.putBlocklist(email, patterns);
+ setBlocklist({ email_address: email, blocked_patterns: patterns, ...saved });
+ onToast?.('Block list saved', 'success');
+ } catch (err) {
+ onToast?.(`Failed to save: ${err.message}`, 'error');
+ throw err;
+ }
+ };
+
+ return (
+
+
+ {/* Tabs */}
+
+
+ {TABS.map((t) => {
+ const Icon = t.icon;
+ const isActive = activeTab === t.id;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Content */}
+ {loading || !rule || !blocklist ? (
+
+
+
+ ) : (
+ <>
+ {activeTab === 'fwd' &&
}
+ {activeTab === 'ooo' &&
}
+ {activeTab === 'block' &&
}
+ >
+ )}
+
+
+ );
+};
+
+export default MailboxSettingsModal;
diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx
new file mode 100644
index 0000000..910f22e
--- /dev/null
+++ b/frontend/src/components/Modal.jsx
@@ -0,0 +1,49 @@
+import React, { useEffect } from 'react';
+import { FiX } from 'react-icons/fi';
+
+const Modal = ({ open, onClose, title, subtitle, children, size = 'md' }) => {
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handler);
+ document.body.style.overflow = 'hidden';
+ return () => {
+ window.removeEventListener('keydown', handler);
+ document.body.style.overflow = '';
+ };
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const widths = {
+ sm: 'max-w-md',
+ md: 'max-w-2xl',
+ lg: 'max-w-4xl',
+ };
+
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}
+ >
+
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
+
+
{children}
+
+
+ );
+};
+
+export default Modal;
diff --git a/frontend/src/components/NewMailboxModal.jsx b/frontend/src/components/NewMailboxModal.jsx
new file mode 100644
index 0000000..913b83b
--- /dev/null
+++ b/frontend/src/components/NewMailboxModal.jsx
@@ -0,0 +1,91 @@
+import React, { useEffect, useRef, useState } from 'react';
+import Modal from './Modal';
+import { mailboxesAPI } from '../services/api';
+
+const NewMailboxModal = ({ open, domain, onClose, onCreated, onToast }) => {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+ const localPartRef = useRef(null);
+
+ useEffect(() => {
+ if (open && domain) {
+ setEmail(`@${domain}`);
+ setPassword('');
+ setError('');
+ // Focus the input and put the cursor at position 0 so the user types
+ // the local part before the existing @domain suffix.
+ requestAnimationFrame(() => {
+ const el = localPartRef.current;
+ if (el) {
+ el.focus();
+ try { el.setSelectionRange(0, 0); } catch { /* some browsers don't allow this */ }
+ }
+ });
+ }
+ }, [open, domain]);
+
+ const submit = async () => {
+ const e = email.trim().toLowerCase();
+ if (!e || !e.includes('@')) { setError('Please enter a valid email address.'); return; }
+ if (!e.endsWith(`@${domain}`)) { setError(`Mailbox must belong to ${domain}.`); return; }
+ if (password.length < 8) { setError('Password must have at least 8 characters.'); return; }
+
+ setBusy(true); setError('');
+ try {
+ await mailboxesAPI.create(e, password);
+ onToast?.(`Mailbox created: ${e}`, 'success');
+ onCreated?.();
+ onClose();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+ setEmail(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('new-mb-pw')?.focus(); } }}
+ className="input-field"
+ autoComplete="off"
+ required
+ />
+
+
+
+
setPassword(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
+ className="input-field"
+ minLength={8}
+ autoComplete="new-password"
+ required
+ />
+
Minimum 8 characters.
+
+ {error &&
{error}
}
+
+
+
+
+
+
+ );
+};
+
+export default NewMailboxModal;
diff --git a/frontend/src/components/OutOfOffice.jsx b/frontend/src/components/OutOfOffice.jsx
new file mode 100644
index 0000000..4e3ac58
--- /dev/null
+++ b/frontend/src/components/OutOfOffice.jsx
@@ -0,0 +1,88 @@
+import React, { useState } from 'react';
+import { FiCalendar } from 'react-icons/fi';
+
+const OutOfOffice = ({ rule, onSave }) => {
+ const [isActive, setIsActive] = useState(rule?.ooo_active || false);
+ const [message, setMessage] = useState(rule?.ooo_message || '');
+ const [isSaving, setIsSaving] = useState(false);
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ try {
+ await onSave({ ooo_active: isActive, ooo_message: message });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
Out of Office Status
+
+ {isActive ? 'Auto-reply is currently active' : 'Auto-reply is currently inactive'}
+
+
+
+
+
+
+
+
+
+
+ {isActive && message && (
+
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default OutOfOffice;
diff --git a/frontend/src/components/PasswordResetModal.jsx b/frontend/src/components/PasswordResetModal.jsx
new file mode 100644
index 0000000..000a991
--- /dev/null
+++ b/frontend/src/components/PasswordResetModal.jsx
@@ -0,0 +1,55 @@
+import React, { useEffect, useState } from 'react';
+import Modal from './Modal';
+import { mailboxesAPI } from '../services/api';
+
+const PasswordResetModal = ({ open, email, onClose, onToast }) => {
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ if (open) { setPassword(''); setError(''); }
+ }, [open, email]);
+
+ const submit = async () => {
+ if (password.length < 8) { setError('Password must have at least 8 characters.'); return; }
+ setBusy(true); setError('');
+ try {
+ await mailboxesAPI.setPassword(email, password);
+ onToast?.(`Password updated for ${email}`, 'success');
+ onClose();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+ setPassword(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
+ className="input-field"
+ minLength={8}
+ autoFocus
+ />
+
+ {error &&
{error}
}
+
+
+
+
+
+
+ );
+};
+
+export default PasswordResetModal;
diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx
new file mode 100644
index 0000000..d91f4b8
--- /dev/null
+++ b/frontend/src/components/Toast.jsx
@@ -0,0 +1,30 @@
+import React, { useEffect } from 'react';
+import { FiCheckCircle, FiXCircle, FiAlertCircle, FiX } from 'react-icons/fi';
+
+const Toast = ({ message, type = 'success', onClose }) => {
+ useEffect(() => {
+ const t = setTimeout(onClose, 3500);
+ return () => clearTimeout(t);
+ }, [onClose]);
+
+ const styles = {
+ success: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-800', icon: },
+ error: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-800', icon: },
+ warning: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-800', icon: },
+ };
+ const s = styles[type] || styles.success;
+
+ return (
+
+
+ {s.icon}
+
{message}
+
+
+
+ );
+};
+
+export default Toast;
diff --git a/frontend/src/components/UsageBar.jsx b/frontend/src/components/UsageBar.jsx
new file mode 100644
index 0000000..a4590c6
--- /dev/null
+++ b/frontend/src/components/UsageBar.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+
+const 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]}`;
+};
+
+const fmtPercent = (n) => {
+ if (n === null || n === undefined || Number.isNaN(Number(n))) return null;
+ const v = Number(n);
+ if (v < 1 && v > 0) return v.toFixed(1);
+ return v.toFixed(0);
+};
+
+export const formatBytes = bytes;
+
+const UsageBar = ({ mailbox }) => {
+ const used = Number(mailbox.used_bytes || 0);
+ const limit = mailbox.quota_bytes === null || mailbox.quota_bytes === undefined
+ ? null : Number(mailbox.quota_bytes);
+ const calc = limit && limit > 0 ? (used / limit) * 100 : null;
+ const raw = mailbox.quota_percent === null || mailbox.quota_percent === undefined
+ ? calc : Number(mailbox.quota_percent);
+ const pct = raw === null || Number.isNaN(raw) ? null : Math.max(0, Math.min(100, raw));
+ const label = limit
+ ? `${bytes(used)} / ${bytes(limit)} (${fmtPercent(pct)}%)`
+ : `${bytes(used)} / unlimited`;
+
+ // Color the bar by usage. No quota -> neutral primary tone.
+ const fillColor = pct === null
+ ? 'bg-primary-500'
+ : pct >= 90 ? 'bg-red-500'
+ : pct >= 75 ? 'bg-amber-500'
+ : 'bg-emerald-500';
+
+ return (
+
+
Disk Usage: {label}
+
+
+ {mailbox.usage_scanned_at
+ ? `quota checked ${new Date(mailbox.usage_scanned_at).toLocaleString()}`
+ : 'quota not checked yet'}
+ {mailbox.message_count !== null && mailbox.message_count !== undefined
+ ? ` · ${Number(mailbox.message_count)} messages` : ''}
+
+
+ );
+};
+
+export default UsageBar;
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..c55af9d
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,55 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ @apply bg-gray-50 font-sans antialiased text-gray-900;
+ }
+}
+
+@layer components {
+ .btn-primary {
+ @apply px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 active:bg-primary-800 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center;
+ }
+ .btn-secondary {
+ @apply px-3 py-1.5 bg-gray-100 text-gray-800 rounded-lg text-sm font-medium hover:bg-gray-200 active:bg-gray-300 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center whitespace-nowrap;
+ }
+ .btn-danger {
+ @apply px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 active:bg-red-800 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center whitespace-nowrap;
+ }
+ .btn-ghost {
+ @apply px-3 py-1.5 bg-transparent text-primary-700 rounded-lg text-sm font-medium hover:bg-primary-50 transition-colors duration-150 inline-flex items-center justify-center whitespace-nowrap;
+ }
+ .input-field {
+ @apply w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-shadow duration-150 outline-none disabled:bg-gray-50 disabled:text-gray-500;
+ }
+ .card {
+ @apply bg-white rounded-xl shadow-sm border border-gray-100 p-6;
+ }
+ .pill {
+ @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700;
+ }
+ .pill-success {
+ @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700;
+ }
+ .pill-warn {
+ @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700;
+ }
+}
+
+@layer utilities {
+ @keyframes slide-in {
+ from { transform: translateX(100%); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+ }
+ .animate-slide-in { animation: slide-in 0.3s ease-out; }
+
+ .custom-scrollbar::-webkit-scrollbar { width: 6px; }
+ .custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
+ .custom-scrollbar::-webkit-scrollbar-thumb {
+ background: #d1d5db;
+ border-radius: 3px;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..54b39dd
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
new file mode 100644
index 0000000..e0f96a7
--- /dev/null
+++ b/frontend/src/services/api.js
@@ -0,0 +1,71 @@
+import axios from 'axios';
+
+// Same-origin: backend serves the built frontend, vite dev proxies /api.
+const api = axios.create({
+ baseURL: '/',
+ withCredentials: true,
+ headers: { 'Content-Type': 'application/json' },
+});
+
+// Centralized error normalization so callers always see err.message + err.statusCode.
+api.interceptors.response.use(
+ (r) => r,
+ (err) => {
+ const status = err.response?.status;
+ const body = err.response?.data;
+ const message = body?.error || err.message || 'Request failed';
+ const wrapped = new Error(message);
+ wrapped.statusCode = status;
+ wrapped.original = err;
+ return Promise.reject(wrapped);
+ }
+);
+
+export const authAPI = {
+ me: async () => (await api.get('/api/auth/me')).data,
+ login: async (email, password) =>
+ (await api.post('/api/auth/login', { email, password })).data,
+ logout: async () => (await api.post('/api/auth/logout')).data,
+};
+
+export const domainsAPI = {
+ list: async (resync = false) =>
+ (await api.get(`/api/domains${resync ? '?resync=true' : ''}`)).data,
+ resync: async () => (await api.post('/api/domains/resync')).data,
+};
+
+export const mailboxesAPI = {
+ list: async (domain, refreshQuota = false) => {
+ const params = new URLSearchParams();
+ if (domain) params.set('domain', domain);
+ if (refreshQuota) params.set('refreshQuota', 'true');
+ return (await api.get(`/api/mailboxes?${params.toString()}`)).data;
+ },
+ create: async (email, password) =>
+ (await api.post('/api/mailboxes', { email, password })).data,
+ remove: async (email) =>
+ (await api.delete(`/api/mailboxes/${encodeURIComponent(email)}`)).data,
+ setPassword: async (email, password) =>
+ (await api.post(
+ `/api/mailboxes/${encodeURIComponent(email)}/password`,
+ { password }
+ )).data,
+ getRules: async (email) =>
+ (await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
+ putRules: async (email, payload) =>
+ (await api.put(
+ `/api/mailboxes/${encodeURIComponent(email)}/rules`,
+ payload
+ )).data,
+ getBlocklist: async (email) =>
+ (await api.get(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`)).data,
+ putBlocklist: async (email, blocked_patterns) =>
+ (await api.put(
+ `/api/mailboxes/${encodeURIComponent(email)}/blocklist`,
+ { blocked_patterns }
+ )).data,
+};
+
+export const auditAPI = {
+ list: async () => (await api.get('/api/audit')).data,
+};
diff --git a/frontend/styles.css b/frontend/styles.css
deleted file mode 100644
index 81afd10..0000000
--- a/frontend/styles.css
+++ /dev/null
@@ -1,39 +0,0 @@
-:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; --ok:#059669; }
-* { box-sizing: border-box; }
-body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--text); }
-button, input, textarea, select { font: inherit; }
-button { border: 0; border-radius: 10px; padding: 10px 14px; background: var(--accent); color: white; cursor: pointer; }
-button.secondary { background: #eef2ff; color: #1e3a8a; }
-button.danger { background: var(--danger); }
-button.ghost { background: transparent; color: var(--accent); }
-input, textarea, select { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: white; }
-textarea { min-height: 110px; }
-.header { display:flex; justify-content:space-between; align-items:center; padding: 18px 28px; background: white; border-bottom:1px solid var(--line); position: sticky; top: 0; z-index: 5; }
-.brand { font-weight: 800; font-size: 20px; }
-.container { max-width: 1280px; margin: 0 auto; padding: 24px; }
-.grid { display:grid; grid-template-columns: 320px 1fr; gap: 20px; align-items:start; }
-.card { background: var(--card); border:1px solid var(--line); border-radius: 18px; padding: 18px; box-shadow: 0 10px 25px rgba(15,23,42,.04); }
-.card h2 { margin:0 0 14px; font-size: 18px; }
-.list { display:flex; flex-direction:column; gap: 8px; }
-.list-item { border:1px solid var(--line); border-radius: 14px; padding: 12px; background:#fff; cursor:pointer; }
-.list-item.active { border-color: var(--accent); background:#eff6ff; }
-.muted { color: var(--muted); font-size: 13px; }
-.row { display:flex; gap: 10px; align-items:center; }
-.row > * { flex: 1; }
-.table { width:100%; border-collapse: collapse; }
-.table th, .table td { text-align:left; padding: 10px; border-bottom:1px solid var(--line); vertical-align: top; }
-.table th { font-size:12px; color:var(--muted); text-transform: uppercase; letter-spacing:.04em; }
-.pill { display:inline-flex; align-items:center; padding: 3px 8px; border-radius: 999px; background:#f3f4f6; font-size:12px; }
-.actions { display:flex; flex-wrap:wrap; gap:8px; }
-.login { min-height: 100vh; display:grid; place-items:center; padding:24px; }
-.login .card { width:min(420px, 100%); }
-.error { color: var(--danger); margin: 10px 0; }
-.success { color: #047857; margin: 10px 0; }
-.modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.35); display:grid; place-items:center; padding: 20px; z-index: 10; }
-.modal { width:min(720px, 100%); max-height: 90vh; overflow:auto; }
-.form-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
-.usage-cell { min-width: 220px; }
-.usage-label { font-size: 13px; font-weight: 650; margin-bottom: 6px; }
-.usage-bar { width: 100%; height: 10px; border-radius: 999px; background: #e5e7eb; overflow: hidden; border: 1px solid #d1d5db; }
-.usage-bar-fill { height: 100%; border-radius: 999px; background: var(--ok); transition: width .25s ease; }
-@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } }
\ No newline at end of file
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..c2576d0
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,29 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ primary: {
+ 50: '#f0f9ff',
+ 100: '#e0f2fe',
+ 200: '#bae6fd',
+ 300: '#7dd3fc',
+ 400: '#38bdf8',
+ 500: '#0ea5e9',
+ 600: '#0284c7',
+ 700: '#0369a1',
+ 800: '#075985',
+ 900: '#0c4a6e',
+ },
+ },
+ fontFamily: {
+ sans: ['Inter', 'system-ui', 'sans-serif'],
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..c0d7235
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: true,
+ port: 3009,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3000',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+})
diff --git a/mailadmin.txt b/mailadmin.txt
new file mode 100644
index 0000000..d887c24
--- /dev/null
+++ b/mailadmin.txt
@@ -0,0 +1,1939 @@
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/migrations/001_init.sql
+================================================================================
+CREATE TABLE IF NOT EXISTS nodes (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL,
+ hostname TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS domains (
+ id SERIAL PRIMARY KEY,
+ domain TEXT UNIQUE NOT NULL,
+ node_name TEXT,
+ status TEXT NOT NULL DEFAULT 'active',
+ first_seen_at TIMESTAMPTZ DEFAULT now(),
+ last_seen_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS mailboxes (
+ id SERIAL PRIMARY KEY,
+ email_address TEXT UNIQUE NOT NULL,
+ domain TEXT NOT NULL,
+ node_name TEXT,
+ status TEXT NOT NULL DEFAULT 'active',
+ used_bytes BIGINT DEFAULT 0,
+ last_usage_scan_at TIMESTAMPTZ,
+ first_seen_at TIMESTAMPTZ DEFAULT now(),
+ last_seen_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS admin_users (
+ id SERIAL PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'super_admin',
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS audit_log (
+ id SERIAL PRIMARY KEY,
+ actor_email TEXT,
+ action TEXT NOT NULL,
+ target TEXT,
+ details JSONB,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- Upgrade existing MVP database
+-- ============================================================
+
+ALTER TABLE nodes
+ ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
+
+ALTER TABLE nodes
+ ADD COLUMN IF NOT EXISTS is_current BOOLEAN DEFAULT false;
+
+ALTER TABLE domains
+ ADD COLUMN IF NOT EXISTS node_name TEXT;
+
+ALTER TABLE domains
+ ADD COLUMN IF NOT EXISTS current_node TEXT;
+
+ALTER TABLE domains
+ ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
+
+ALTER TABLE domains
+ ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ;
+
+ALTER TABLE domains
+ ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT '';
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS node_name TEXT;
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS local_part TEXT;
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS quota_bytes BIGINT;
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS quota_percent NUMERIC(8,3);
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS message_count BIGINT;
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS message_limit BIGINT;
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS usage_scanned_at TIMESTAMPTZ;
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
+
+ALTER TABLE mailboxes
+ ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
+
+ALTER TABLE admin_users
+ ADD COLUMN IF NOT EXISTS allowed_domains TEXT[] NOT NULL DEFAULT '{}';
+
+ALTER TABLE admin_users
+ ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
+
+ALTER TABLE admin_users
+ ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
+
+ALTER TABLE audit_log
+ ADD COLUMN IF NOT EXISTS target_type TEXT;
+
+ALTER TABLE audit_log
+ ADD COLUMN IF NOT EXISTS target_id TEXT;
+
+ALTER TABLE audit_log
+ ADD COLUMN IF NOT EXISTS ip_address TEXT;
+
+ALTER TABLE audit_log
+ ALTER COLUMN details SET DEFAULT '{}';
+
+-- ============================================================
+-- Compatibility backfills
+-- ============================================================
+
+-- If an earlier version used current_node instead of node_name, copy it.
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'domains'
+ AND column_name = 'current_node'
+ ) THEN
+ EXECUTE '
+ UPDATE domains
+ SET node_name = current_node
+ WHERE node_name IS NULL
+ AND current_node IS NOT NULL
+ ';
+ END IF;
+END $$;
+
+-- If node_name is still empty, set it to current app node from env when available.
+DO $$
+DECLARE
+ app_node_name TEXT;
+BEGIN
+ app_node_name := current_setting('app.node_name', true);
+
+ IF app_node_name IS NOT NULL AND app_node_name <> '' THEN
+ UPDATE domains
+ SET node_name = app_node_name
+ WHERE node_name IS NULL;
+
+ UPDATE mailboxes
+ SET node_name = app_node_name
+ WHERE node_name IS NULL;
+ END IF;
+END $$;
+
+-- Fill current_node from node_name for compatibility with newer code variants.
+UPDATE domains
+SET current_node = node_name
+WHERE current_node IS NULL
+ AND node_name IS NOT NULL;
+
+-- Fill local_part for existing rows.
+UPDATE mailboxes
+SET local_part = split_part(email_address, '@', 1)
+WHERE local_part IS NULL
+ AND email_address LIKE '%@%';
+
+-- Fill mailbox node_name from domain node_name where possible.
+UPDATE mailboxes m
+SET node_name = d.node_name
+FROM domains d
+WHERE m.domain = d.domain
+ AND m.node_name IS NULL
+ AND d.node_name IS NOT NULL;
+
+-- Keep old and new usage timestamp columns in sync initially.
+-- Dynamic EXECUTE is important here, otherwise PostgreSQL may still parse a missing column.
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'mailboxes'
+ AND column_name = 'last_usage_scan_at'
+ ) THEN
+ EXECUTE '
+ UPDATE mailboxes
+ SET usage_scanned_at = last_usage_scan_at
+ WHERE usage_scanned_at IS NULL
+ AND last_usage_scan_at IS NOT NULL
+ ';
+ END IF;
+END $$;
+
+-- Backfill new audit target columns from old target column.
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'audit_log'
+ AND column_name = 'target'
+ ) THEN
+ EXECUTE '
+ UPDATE audit_log
+ SET target_id = target
+ WHERE target_id IS NULL
+ AND target IS NOT NULL
+ ';
+ END IF;
+END $$;
+
+UPDATE audit_log
+SET target_type = 'unknown'
+WHERE target_type IS NULL;
+
+-- ============================================================
+-- Final constraints/defaults after backfill
+-- ============================================================
+
+ALTER TABLE domains
+ ALTER COLUMN node_name SET DEFAULT 'unknown';
+
+ALTER TABLE mailboxes
+ ALTER COLUMN node_name SET DEFAULT 'unknown';
+
+UPDATE domains
+SET node_name = 'unknown'
+WHERE node_name IS NULL;
+
+UPDATE mailboxes
+SET node_name = 'unknown'
+WHERE node_name IS NULL;
+
+ALTER TABLE domains
+ ALTER COLUMN node_name SET NOT NULL;
+
+ALTER TABLE mailboxes
+ ALTER COLUMN node_name SET NOT NULL;
+
+-- ============================================================
+-- Useful indexes
+-- ============================================================
+
+CREATE INDEX IF NOT EXISTS idx_domains_node_name
+ ON domains(node_name);
+
+CREATE INDEX IF NOT EXISTS idx_domains_current_node
+ ON domains(current_node);
+
+CREATE INDEX IF NOT EXISTS idx_mailboxes_domain
+ ON mailboxes(domain);
+
+CREATE INDEX IF NOT EXISTS idx_mailboxes_node_name
+ ON mailboxes(node_name);
+
+CREATE INDEX IF NOT EXISTS idx_audit_created
+ ON audit_log(created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_admin_users_allowed_domains
+ ON admin_users USING GIN(allowed_domains);
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/middleware/auth.ts
+================================================================================
+import type { NextFunction, Request, Response } from 'express';
+import jwt from 'jsonwebtoken';
+import { config } from '../config.js';
+
+export interface AuthUser {
+ id: string;
+ email: string;
+ role: string;
+ allowed_domains: string[];
+}
+
+declare global {
+ namespace Express {
+ interface Request { user?: AuthUser }
+ }
+}
+
+export function signUser(user: AuthUser): string {
+ return jwt.sign(user, config.jwtSecret, { expiresIn: '12h' });
+}
+
+export function requireAuth(req: Request, res: Response, next: NextFunction): void {
+ const token = req.cookies?.mailadmin_token;
+ if (!token) {
+ res.status(401).json({ error: 'Not authenticated' });
+ return;
+ }
+ try {
+ req.user = jwt.verify(token, config.jwtSecret) as AuthUser;
+ next();
+ } catch {
+ res.status(401).json({ error: 'Invalid session' });
+ }
+}
+
+export function canAccessDomain(user: AuthUser, domain: string): boolean {
+ return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase());
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/routes/audit.ts
+================================================================================
+import { Router } from 'express';
+import { pool } from '../db.js';
+import { requireAuth } from '../middleware/auth.js';
+
+export const auditRouter = Router();
+auditRouter.use(requireAuth);
+
+auditRouter.get('/', async (_req, res) => {
+ const result = await pool.query(
+ `SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
+ );
+ res.json(result.rows);
+});
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/routes/auth.ts
+================================================================================
+import { Router } from 'express';
+import bcrypt from 'bcryptjs';
+import { z } from 'zod';
+import { pool } from '../db.js';
+import { config } from '../config.js';
+import { requireAuth, signUser } from '../middleware/auth.js';
+
+export const authRouter = Router();
+
+const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1) });
+
+authRouter.post('/login', async (req, res) => {
+ const body = loginSchema.parse(req.body);
+ const result = await pool.query(
+ `SELECT id, email, password_hash, role, allowed_domains FROM admin_users WHERE email=$1 AND active=true`,
+ [body.email.toLowerCase()],
+ );
+ const user = result.rows[0];
+ if (!user || !(await bcrypt.compare(body.password, user.password_hash))) {
+ res.status(401).json({ error: 'Invalid email or password' });
+ return;
+ }
+ const token = signUser({ id: user.id, email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] });
+ res.cookie('mailadmin_token', token, {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: config.cookieSecure,
+ maxAge: 12 * 60 * 60 * 1000,
+ });
+ res.json({ email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] });
+});
+
+authRouter.post('/logout', (_req, res) => {
+ res.clearCookie('mailadmin_token');
+ res.json({ ok: true });
+});
+
+authRouter.get('/me', requireAuth, (req, res) => {
+ res.json(req.user);
+});
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/routes/domains.ts
+================================================================================
+import { Router } from 'express';
+import { pool } from '../db.js';
+import { config } from '../config.js';
+import { requireAuth, canAccessDomain } from '../middleware/auth.js';
+import { SyncService } from '../services/sync.js';
+import { audit } from '../services/audit.js';
+
+export const domainsRouter = Router();
+domainsRouter.use(requireAuth);
+
+const sync = new SyncService();
+
+domainsRouter.post('/resync', async (req, res) => {
+ const result = await sync.syncFromDms();
+ await audit(req.user!.email, 'domains.resync', 'node', config.nodeName, result, req.ip);
+ res.json(result);
+});
+
+domainsRouter.get('/', async (req, res) => {
+ if (req.query.resync === 'true') await sync.syncFromDms();
+ const result = await pool.query(
+ `SELECT d.*, COUNT(m.email_address) FILTER (WHERE m.status='active')::int AS active_mailboxes,
+ COALESCE(SUM(m.used_bytes) FILTER (WHERE m.status='active'),0)::bigint AS used_bytes
+ FROM domains d
+ LEFT JOIN mailboxes m ON m.domain=d.domain
+ WHERE d.current_node=$1
+ GROUP BY d.id
+ ORDER BY d.domain`,
+ [config.nodeName],
+ );
+ const rows = result.rows.filter((r) => canAccessDomain(req.user!, r.domain));
+ res.json(rows);
+});
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/routes/mailboxes.ts
+================================================================================
+import { Router } from 'express';
+import { z } from 'zod';
+import { pool } from '../db.js';
+import { config } from '../config.js';
+import { requireAuth, canAccessDomain } from '../middleware/auth.js';
+import { DmsService } from '../services/dms.js';
+import { SyncService } from '../services/sync.js';
+import { DynamoRulesService } from '../services/dynamodb.js';
+import { audit } from '../services/audit.js';
+import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
+
+export const mailboxesRouter = Router();
+mailboxesRouter.use(requireAuth);
+
+const dms = new DmsService();
+const sync = new SyncService(dms);
+const dynamo = new DynamoRulesService();
+
+function ensureDomain(req: any, domain: string): void {
+ if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 });
+}
+
+async function refreshQuotaForMailbox(emailAddress: string): Promise {
+ const quota = await dms.getMailboxQuota(emailAddress);
+ await pool.query(
+ `UPDATE mailboxes
+ SET used_bytes=$2,
+ quota_bytes=$3,
+ quota_percent=$4,
+ message_count=$5,
+ message_limit=$6,
+ usage_scanned_at=now(),
+ updated_at=now()
+ WHERE email_address=$1`,
+ [
+ emailAddress,
+ quota.usedBytes,
+ quota.quotaBytes,
+ quota.quotaPercent,
+ quota.messageCount,
+ quota.messageLimit,
+ ],
+ );
+}
+
+async function refreshQuotaForDomain(req: any, domain: string): Promise {
+ ensureDomain(req, domain);
+
+ const rows = (await pool.query(
+ `SELECT email_address, domain
+ FROM mailboxes
+ WHERE node_name=$1 AND domain=$2 AND status='active'
+ ORDER BY email_address`,
+ [config.nodeName, domain],
+ )).rows;
+
+ let count = 0;
+ for (const row of rows) {
+ if (!canAccessDomain(req.user!, row.domain)) continue;
+ try {
+ await refreshQuotaForMailbox(row.email_address);
+ count++;
+ } catch (err) {
+ console.warn(`Could not refresh quota for ${row.email_address}:`, err);
+ }
+ }
+
+ return count;
+}
+
+mailboxesRouter.get('/', async (req, res) => {
+ const domain = String(req.query.domain ?? '').toLowerCase();
+ const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
+
+ if (domain) {
+ ensureDomain(req, domain);
+ if (refreshQuota) {
+ const count = await refreshQuotaForDomain(req, domain);
+ await audit(req.user!.email, 'mailbox.quota_refresh', 'domain', domain, { count }, req.ip);
+ }
+ }
+
+ const params: unknown[] = [config.nodeName];
+ let where = 'WHERE node_name=$1';
+ if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
+
+ const result = await pool.query(
+ `SELECT * FROM mailboxes ${where} ORDER BY domain, local_part`,
+ params,
+ );
+ res.json(result.rows.filter((r) => canAccessDomain(req.user!, r.domain)));
+});
+
+mailboxesRouter.post('/', async (req, res) => {
+ const body = z.object({ email: z.string().email(), password: z.string().min(8) }).parse(req.body);
+ const email = normalizeEmail(body.email);
+ const domain = domainFromEmail(email);
+ ensureDomain(req, domain);
+ await dms.addMailbox(email, body.password);
+ await sync.syncFromDms();
+ await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
+ await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip);
+ res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
+});
+
+mailboxesRouter.delete('/:email', async (req, res) => {
+ const email = normalizeEmail(req.params.email);
+ const domain = domainFromEmail(email);
+ ensureDomain(req, domain);
+ await dms.deleteMailbox(email);
+ await dms.syncSesDomain(domain).catch(() => undefined);
+ await pool.query(`UPDATE mailboxes SET status='deleted', deleted_at=now(), updated_at=now() WHERE email_address=$1`, [email]);
+ await audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip);
+ res.json({ ok: true });
+});
+
+mailboxesRouter.post('/:email/password', async (req, res) => {
+ const body = z.object({ password: z.string().min(8) }).parse(req.body);
+ const email = normalizeEmail(req.params.email);
+ ensureDomain(req, domainFromEmail(email));
+ await dms.updatePassword(email, body.password);
+ await audit(req.user!.email, 'mailbox.password_update', 'mailbox', email, {}, req.ip);
+ res.json({ ok: true });
+});
+
+mailboxesRouter.get('/:email/rules', async (req, res) => {
+ const email = normalizeEmail(req.params.email);
+ ensureDomain(req, domainFromEmail(email));
+ res.json(await dynamo.getRules(email));
+});
+
+mailboxesRouter.put('/:email/rules', async (req, res) => {
+ const email = normalizeEmail(req.params.email);
+ ensureDomain(req, domainFromEmail(email));
+ const body = z.object({ ooo_active: z.boolean().optional(), ooo_message: z.string().optional(), forwards: z.array(z.string().email()).optional() }).parse(req.body);
+ const saved = await dynamo.putRules({ email_address: email, ooo_active: body.ooo_active, ooo_message: body.ooo_message, forwards: body.forwards });
+ await audit(req.user!.email, 'mailbox.rules_update', 'mailbox', email, saved, req.ip);
+ res.json(saved);
+});
+
+mailboxesRouter.get('/:email/blocklist', async (req, res) => {
+ const email = normalizeEmail(req.params.email);
+ ensureDomain(req, domainFromEmail(email));
+ res.json(await dynamo.getBlocklist(email));
+});
+
+mailboxesRouter.put('/:email/blocklist', async (req, res) => {
+ const email = normalizeEmail(req.params.email);
+ ensureDomain(req, domainFromEmail(email));
+ const body = z.object({ blocked_patterns: z.array(z.string()) }).parse(req.body);
+ const saved = await dynamo.putBlocklist(email, body.blocked_patterns);
+ await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip);
+ res.json(saved);
+});
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/services/audit.ts
+================================================================================
+import { pool } from '../db.js';
+
+export async function audit(actorEmail: string | null, action: string, targetType: string, targetId: string, details: unknown, ip?: string): Promise {
+ await pool.query(
+ `INSERT INTO audit_log(actor_email, action, target_type, target_id, details, ip_address)
+ VALUES($1,$2,$3,$4,$5,$6)`,
+ [actorEmail, action, targetType, targetId, details ?? {}, ip ?? null],
+ );
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/services/dms.ts
+================================================================================
+import { existsSync } from 'node:fs';
+import { config } from '../config.js';
+import { run } from '../utils/shell.js';
+import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
+
+export interface DmsAccount {
+ email: string;
+ localPart: string;
+ domain: string;
+}
+
+export interface DmsQuota {
+ usedBytes: number;
+ quotaBytes: number | null;
+ quotaPercent: number | null;
+ messageCount: number | null;
+ messageLimit: number | null;
+}
+
+function parseAccounts(output: string): DmsAccount[] {
+ const accounts: DmsAccount[] = [];
+
+ for (const line of output.split('\n')) {
+ const match = line.match(/([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/i);
+ if (!match) continue;
+
+ const email = normalizeEmail(match[1]);
+ accounts.push({
+ email,
+ localPart: localPartFromEmail(email),
+ domain: domainFromEmail(email),
+ });
+ }
+
+ return [...new Map(accounts.map((a) => [a.email, a])).values()]
+ .sort((a, b) => a.email.localeCompare(b.email));
+}
+
+function parseQuotaNumber(value: string | undefined): number | null {
+ if (!value || value === '-' || value.trim() === '') return null;
+
+ const parsed = Number.parseInt(value.trim(), 10);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function parseDoveadmQuota(output: string): DmsQuota {
+ let usedStorageKb: number | null = null;
+ let storageLimitKb: number | null = null;
+ let storagePercent: number | null = null;
+ let messageCount: number | null = null;
+ let messageLimit: number | null = null;
+
+ for (const line of output.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.toLowerCase().startsWith('quota name')) continue;
+
+ const parts = trimmed.split(/\s+/);
+ const typeIndex = parts.findIndex((p) => p === 'STORAGE' || p === 'MESSAGE');
+
+ if (typeIndex < 0) continue;
+
+ const type = parts[typeIndex];
+ const value = parseQuotaNumber(parts[typeIndex + 1]);
+ const limit = parseQuotaNumber(parts[typeIndex + 2]);
+ const percent = parseQuotaNumber(parts[typeIndex + 3]);
+
+ if (type === 'STORAGE') {
+ usedStorageKb = value;
+ storageLimitKb = limit;
+ storagePercent = percent;
+ }
+
+ if (type === 'MESSAGE') {
+ messageCount = value;
+ messageLimit = limit;
+ }
+ }
+
+ const usedBytes = Math.max(0, usedStorageKb ?? 0) * 1024;
+ const quotaBytes = storageLimitKb && storageLimitKb > 0
+ ? storageLimitKb * 1024
+ : null;
+
+ const calculatedPercent = quotaBytes && quotaBytes > 0
+ ? (usedBytes / quotaBytes) * 100
+ : null;
+
+ return {
+ usedBytes,
+ quotaBytes,
+ quotaPercent: calculatedPercent ?? storagePercent,
+ messageCount,
+ messageLimit,
+ };
+}
+
+export class DmsService {
+ async assertDockerAccess(): Promise {
+ await run('docker', ['ps', '--format', '{{.Names}}'], 30000);
+ }
+
+ async listAccounts(): Promise {
+ console.log(`[dms] listing accounts from container: ${config.dmsContainer}`);
+
+ const { stdout } = await run(
+ 'docker',
+ ['exec', config.dmsContainer, 'setup', 'email', 'list'],
+ 60000,
+ );
+
+ const accounts = parseAccounts(stdout);
+
+ console.log(`[dms] found ${accounts.length} account(s)`);
+
+ return accounts;
+ }
+
+ async addMailbox(email: string, password: string): Promise {
+ const normalized = normalizeEmail(email);
+
+ console.log(`[dms] creating mailbox: ${normalized}`);
+
+ await this.assertDockerAccess();
+
+ if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) {
+ console.log(`[dms] using manage script: ${config.manageMailUserScript}`);
+ await run(config.manageMailUserScript, ['add', normalized, password]);
+ console.log(`[dms] mailbox created through manage script: ${normalized}`);
+ return;
+ }
+
+ console.log(`[dms] using docker-mailserver setup command`);
+
+ await run(
+ 'docker',
+ ['exec', config.dmsContainer, 'setup', 'email', 'add', normalized, password],
+ 120000,
+ );
+
+ console.log(`[dms] mailbox created: ${normalized}`);
+ }
+
+ async deleteMailbox(email: string): Promise {
+ const normalized = normalizeEmail(email);
+
+ console.log(`[dms] deleting mailbox: ${normalized}`);
+
+ await this.assertDockerAccess();
+
+ if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) {
+ console.log(`[dms] using manage script: ${config.manageMailUserScript}`);
+ await run(config.manageMailUserScript, ['del', normalized]);
+ console.log(`[dms] mailbox deleted through manage script: ${normalized}`);
+ return;
+ }
+
+ await run(
+ 'docker',
+ ['exec', config.dmsContainer, 'setup', 'email', 'del', normalized],
+ 120000,
+ );
+
+ console.log(`[dms] mailbox deleted: ${normalized}`);
+ }
+
+ async updatePassword(email: string, password: string): Promise {
+ const normalized = normalizeEmail(email);
+
+ console.log(`[dms] updating password for mailbox: ${normalized}`);
+
+ await this.assertDockerAccess();
+
+ await run(
+ 'docker',
+ ['exec', config.dmsContainer, 'setup', 'email', 'update', normalized, password],
+ 120000,
+ );
+
+ console.log(`[dms] password updated for mailbox: ${normalized}`);
+ }
+
+ async syncSesDomain(domain: string): Promise {
+ const normalizedDomain = domain.toLowerCase();
+
+ if (config.manageMailUserScript && existsSync(config.manageMailUserScript)) {
+ console.log(`[dms] syncing SES for domain through manage script: ${normalizedDomain}`);
+ await run(config.manageMailUserScript, ['sync', normalizedDomain], 120000);
+ return;
+ }
+
+ console.log(`[dms] no manage script configured, skipping SES sync for ${normalizedDomain}`);
+ }
+
+ async getMailboxQuota(email: string): Promise {
+ const normalized = normalizeEmail(email);
+
+ console.log(`[dms] reading quota for mailbox: ${normalized}`);
+
+ const { stdout } = await run(
+ 'docker',
+ ['exec', config.dmsContainer, 'doveadm', 'quota', 'get', '-u', normalized],
+ 60000,
+ );
+
+ return parseDoveadmQuota(stdout);
+ }
+}
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/services/dynamodb.ts
+================================================================================
+import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
+import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
+import { config } from '../config.js';
+import { normalizeEmail } from '../utils/email.js';
+
+export interface EmailRule {
+ email_address: string;
+ ooo_active?: boolean;
+ ooo_message?: string;
+ ooo_content_type?: string;
+ forwards?: string[];
+}
+
+export interface BlockList {
+ email_address: string;
+ blocked_patterns: string[];
+}
+
+export class DynamoRulesService {
+ private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), {
+ marshallOptions: { removeUndefinedValues: true },
+ });
+
+ async getRules(email: string): Promise {
+ const email_address = normalizeEmail(email);
+ const resp = await this.doc.send(new GetCommand({ TableName: config.rulesTable, Key: { email_address } }));
+ return (resp.Item as EmailRule) ?? { email_address, ooo_active: false, ooo_message: '', ooo_content_type: 'text/plain', forwards: [] };
+ }
+
+ async putRules(rule: EmailRule): Promise {
+ const item: EmailRule = {
+ email_address: normalizeEmail(rule.email_address),
+ ooo_active: !!rule.ooo_active,
+ ooo_message: rule.ooo_message ?? '',
+ ooo_content_type: rule.ooo_content_type ?? 'text/plain',
+ forwards: (rule.forwards ?? []).map(normalizeEmail).filter(Boolean),
+ };
+ await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item }));
+ return item;
+ }
+
+ async getBlocklist(email: string): Promise {
+ const email_address = normalizeEmail(email);
+ const resp = await this.doc.send(new GetCommand({ TableName: config.blockedTable, Key: { email_address } }));
+ return (resp.Item as BlockList) ?? { email_address, blocked_patterns: [] };
+ }
+
+ async putBlocklist(email: string, patterns: string[]): Promise {
+ const item: BlockList = {
+ email_address: normalizeEmail(email),
+ blocked_patterns: patterns.map((p) => p.trim().toLowerCase()).filter(Boolean),
+ };
+ if (item.blocked_patterns.length === 0) {
+ await this.doc.send(new DeleteCommand({ TableName: config.blockedTable, Key: { email_address: item.email_address } }));
+ return item;
+ }
+ await this.doc.send(new PutCommand({ TableName: config.blockedTable, Item: item }));
+ return item;
+ }
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/services/sync.ts
+================================================================================
+import { pool } from '../db.js';
+import { config } from '../config.js';
+import { DmsService } from './dms.js';
+
+export class SyncService {
+ constructor(private dms = new DmsService()) {}
+
+ async syncFromDms(): Promise<{ domains: number; mailboxes: number }> {
+ const accounts = await this.dms.listAccounts();
+ const domains = [...new Set(accounts.map((a) => a.domain))].sort();
+
+ await pool.query('BEGIN');
+ try {
+ for (const domain of domains) {
+ await pool.query(
+ `INSERT INTO domains(domain, current_node, status, last_seen_at, last_synced_at)
+ VALUES($1,$2,'active',now(),now())
+ ON CONFLICT(domain) DO UPDATE SET
+ current_node = EXCLUDED.current_node,
+ status = 'active',
+ last_seen_at = now(),
+ last_synced_at = now(),
+ updated_at = now()`,
+ [domain, config.nodeName],
+ );
+ }
+
+ const localEmails = accounts.map((a) => a.email);
+ for (const account of accounts) {
+ await pool.query(
+ `INSERT INTO mailboxes(email_address, local_part, domain, node_name, status, deleted_at)
+ VALUES($1,$2,$3,$4,'active',NULL)
+ ON CONFLICT(email_address) DO UPDATE SET
+ local_part = EXCLUDED.local_part,
+ domain = EXCLUDED.domain,
+ node_name = EXCLUDED.node_name,
+ status = 'active',
+ deleted_at = NULL,
+ updated_at = now()`,
+ [account.email, account.localPart, account.domain, config.nodeName],
+ );
+ }
+
+ await pool.query(
+ `UPDATE domains
+ SET status='missing_on_node', last_synced_at=now(), updated_at=now()
+ WHERE current_node=$1 AND NOT (domain = ANY($2::text[]))`,
+ [config.nodeName, domains],
+ );
+
+ await pool.query(
+ `UPDATE mailboxes
+ SET status='missing_on_node', updated_at=now()
+ WHERE node_name=$1 AND status='active' AND NOT (email_address = ANY($2::text[]))`,
+ [config.nodeName, localEmails],
+ );
+
+ await pool.query('COMMIT');
+ return { domains: domains.length, mailboxes: accounts.length };
+ } catch (err) {
+ await pool.query('ROLLBACK');
+ throw err;
+ }
+ }
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/utils/email.ts
+================================================================================
+export function normalizeEmail(email: string): string {
+ return email.trim().toLowerCase();
+}
+
+export function domainFromEmail(email: string): string {
+ const normalized = normalizeEmail(email);
+ const at = normalized.lastIndexOf('@');
+ if (at < 1 || at === normalized.length - 1) throw new Error('Invalid email address');
+ return normalized.slice(at + 1);
+}
+
+export function localPartFromEmail(email: string): string {
+ const normalized = normalizeEmail(email);
+ const at = normalized.lastIndexOf('@');
+ if (at < 1) throw new Error('Invalid email address');
+ return normalized.slice(0, at);
+}
+
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let value = bytes;
+ let i = 0;
+ while (value >= 1024 && i < units.length - 1) {
+ value /= 1024;
+ i++;
+ }
+ return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/utils/shell.ts
+================================================================================
+import { spawn } from 'node:child_process';
+
+export interface RunResult {
+ stdout: string;
+ stderr: string;
+ code: number;
+}
+
+export function run(
+ command: string,
+ args: string[] = [],
+ timeoutMs = 120000,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const printable = [command, ...args].join(' ');
+ console.log(`[shell] running: ${printable}`);
+
+ const child = spawn(command, args, {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: process.env,
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ const timer = setTimeout(() => {
+ child.kill('SIGKILL');
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${printable}`));
+ }, timeoutMs);
+
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk.toString();
+ });
+
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk.toString();
+ });
+
+ child.on('error', (err) => {
+ clearTimeout(timer);
+ console.error(`[shell] failed to start: ${printable}`);
+ console.error(`[shell] error: ${err.message}`);
+ reject(err);
+ });
+
+ child.on('close', (code) => {
+ clearTimeout(timer);
+
+ const exitCode = code ?? -1;
+
+ if (stdout.trim()) {
+ console.log(`[shell] stdout for ${printable}:\n${stdout.trim()}`);
+ }
+
+ if (stderr.trim()) {
+ console.warn(`[shell] stderr for ${printable}:\n${stderr.trim()}`);
+ }
+
+ if (exitCode !== 0) {
+ const err = new Error(
+ `Command failed with exit code ${exitCode}: ${printable}\n${stderr || stdout}`,
+ );
+ reject(err);
+ return;
+ }
+
+ console.log(`[shell] completed: ${printable}`);
+ resolve({ stdout, stderr, code: exitCode });
+ });
+ });
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/config.ts
+================================================================================
+import 'dotenv/config';
+
+export const config = {
+ env: process.env.NODE_ENV ?? 'development',
+ port: parseInt(process.env.PORT ?? '3000', 10),
+ publicDir: process.env.PUBLIC_DIR ?? '../frontend',
+ databaseUrl: process.env.DATABASE_URL ?? 'postgres://mailadmin:mailadmin@localhost:5432/mailadmin',
+ jwtSecret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
+ cookieSecure: (process.env.COOKIE_SECURE ?? 'false').toLowerCase() === 'true',
+
+ adminEmail: process.env.ADMIN_EMAIL ?? 'admin@example.com',
+ adminPassword: process.env.ADMIN_PASSWORD ?? 'ChangeMe123!',
+
+ nodeName: process.env.NODE_NAME ?? 'node1',
+ nodeHostname: process.env.NODE_HOSTNAME ?? 'node1.email-srvr.com',
+
+ dmsContainer: process.env.DMS_CONTAINER ?? 'mailserver',
+ manageMailUserScript: process.env.MANAGE_MAIL_USER_SCRIPT ?? '',
+ mailDataPath: process.env.MAILDATA_PATH ?? '/mail-data',
+
+ awsRegion: process.env.AWS_REGION ?? 'us-east-2',
+ rulesTable: process.env.DYNAMODB_RULES_TABLE ?? 'email-rules',
+ blockedTable: process.env.DYNAMODB_BLOCKED_TABLE ?? 'email-blocked-senders',
+};
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/db.ts
+================================================================================
+import pg from 'pg';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const { Pool } = pg;
+
+export const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+});
+
+function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function getBackendRootDir(): string {
+ const currentFile = fileURLToPath(import.meta.url);
+ const currentDir = path.dirname(currentFile);
+
+ /**
+ * During development:
+ * backend/src/db.ts
+ *
+ * After TypeScript build:
+ * backend/dist/db.js
+ *
+ * We want:
+ * backend/
+ */
+ if (currentDir.endsWith('/src') || currentDir.endsWith('\\src')) {
+ return path.resolve(currentDir, '..');
+ }
+
+ if (currentDir.endsWith('/dist') || currentDir.endsWith('\\dist')) {
+ return path.resolve(currentDir, '..');
+ }
+
+ return path.resolve(currentDir, '..');
+}
+
+async function tableExists(tableName: string): Promise {
+ const result = await pool.query(
+ `
+ SELECT EXISTS (
+ SELECT 1
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = $1
+ ) AS exists
+ `,
+ [tableName],
+ );
+
+ return Boolean(result.rows[0]?.exists);
+}
+
+async function runMigrations() {
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ id SERIAL PRIMARY KEY,
+ filename TEXT UNIQUE NOT NULL,
+ executed_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+ `);
+
+ const backendRoot = getBackendRootDir();
+ const migrationsDir = path.join(backendRoot, 'migrations');
+
+ let files: string[] = [];
+
+ try {
+ files = await fs.readdir(migrationsDir);
+ } catch (err: any) {
+ throw new Error(
+ `Migrations directory not found: ${migrationsDir}. ` +
+ `Make sure backend/migrations is copied into the Docker image. Original error: ${err.message}`,
+ );
+ }
+
+ const sqlFiles = files
+ .filter((file) => file.endsWith('.sql'))
+ .sort((a, b) => a.localeCompare(b));
+
+ for (const file of sqlFiles) {
+ const alreadyExecuted = await pool.query(
+ `SELECT 1 FROM schema_migrations WHERE filename = $1`,
+ [file],
+ );
+
+ /**
+ * Special case:
+ * The first MVP version created tables inline in db.ts and had no migration system.
+ * Therefore 001_init.sql must still run once to apply ALTER TABLE upgrades.
+ *
+ * For later migrations this mechanism prevents duplicate execution.
+ */
+ const shouldRun =
+ alreadyExecuted.rowCount === 0 ||
+ file === '001_init.sql';
+
+ if (!shouldRun) {
+ continue;
+ }
+
+ const fullPath = path.join(migrationsDir, file);
+ const sql = await fs.readFile(fullPath, 'utf8');
+
+ console.log(`Running database migration: ${file}`);
+
+ await pool.query('BEGIN');
+
+ try {
+ await pool.query(sql);
+
+ await pool.query(
+ `
+ INSERT INTO schema_migrations(filename)
+ VALUES ($1)
+ ON CONFLICT (filename) DO UPDATE
+ SET executed_at = now()
+ `,
+ [file],
+ );
+
+ await pool.query('COMMIT');
+ console.log(`✓ Migration completed: ${file}`);
+ } catch (err) {
+ await pool.query('ROLLBACK');
+ console.error(`✗ Migration failed: ${file}`);
+ throw err;
+ }
+ }
+}
+
+async function seedInitialAdmin() {
+ const adminEmail = process.env.MAILADMIN_ADMIN_EMAIL;
+ const adminPassword = process.env.MAILADMIN_ADMIN_PASSWORD;
+
+ if (!adminEmail || !adminPassword) {
+ console.warn(
+ 'MAILADMIN_ADMIN_EMAIL or MAILADMIN_ADMIN_PASSWORD is not set. Skipping initial admin creation.',
+ );
+ return;
+ }
+
+ const existing = await pool.query(
+ `SELECT id FROM admin_users WHERE email = $1`,
+ [adminEmail.toLowerCase()],
+ );
+
+ if ((existing.rowCount ?? 0) > 0) {
+ return;
+ }
+
+ /**
+ * bcryptjs is already used by the auth route in the MVP.
+ * Dynamic import avoids requiring it before DB startup.
+ */
+ const bcrypt = await import('bcryptjs');
+ const passwordHash = await bcrypt.hash(adminPassword, 12);
+
+ await pool.query(
+ `
+ INSERT INTO admin_users (
+ email,
+ password_hash,
+ role,
+ allowed_domains,
+ active
+ )
+ VALUES ($1, $2, 'super_admin', '{}', true)
+ `,
+ [adminEmail.toLowerCase(), passwordHash],
+ );
+
+ console.log(`✓ Initial super admin created: ${adminEmail}`);
+}
+
+export async function initDb() {
+ const maxAttempts = 30;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ await pool.query('SELECT 1');
+ console.log('✓ PostgreSQL connected');
+
+ await runMigrations();
+
+ const hasAdminUsers = await tableExists('admin_users');
+ if (hasAdminUsers) {
+ await seedInitialAdmin();
+ }
+
+ return;
+ } catch (err: any) {
+ console.warn(
+ `PostgreSQL not ready yet (${attempt}/${maxAttempts}): ${err.message}`,
+ );
+
+ if (attempt === maxAttempts) {
+ throw err;
+ }
+
+ await sleep(2000);
+ }
+ }
+}
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/src/server.ts
+================================================================================
+import express from 'express';
+import cookieParser from 'cookie-parser';
+import cors from 'cors';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { config } from './config.js';
+import { initDb } from './db.js';
+import { authRouter } from './routes/auth.js';
+import { domainsRouter } from './routes/domains.js';
+import { mailboxesRouter } from './routes/mailboxes.js';
+import { auditRouter } from './routes/audit.js';
+import { SyncService } from './services/sync.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const app = express();
+
+app.use(cors({ origin: true, credentials: true }));
+app.use(express.json({ limit: '1mb' }));
+app.use(cookieParser());
+
+app.use((req, res, next) => {
+ const started = Date.now();
+
+ res.on('finish', () => {
+ const ms = Date.now() - started;
+ console.log(`[http] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
+ });
+
+ next();
+});
+
+app.get('/api/health', (_req, res) => {
+ res.json({
+ ok: true,
+ node: config.nodeName,
+ hostname: config.nodeHostname,
+ });
+});
+
+app.use('/api/auth', authRouter);
+app.use('/api/domains', domainsRouter);
+app.use('/api/mailboxes', mailboxesRouter);
+app.use('/api/audit', auditRouter);
+
+app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ const status = err.status ?? err.statusCode ?? 500;
+
+ console.error(`[error] ${req.method} ${req.originalUrl}`);
+ console.error(err?.stack || err?.message || err);
+
+ res.status(status).json({
+ error: err.message ?? 'Internal server error',
+ });
+});
+
+const publicDir = config.publicDir.startsWith('/')
+ ? config.publicDir
+ : resolve(__dirname, config.publicDir);
+
+console.log(`Serving frontend from: ${publicDir}`);
+
+// Avoid stale frontend JS while we are actively developing the MVP.
+app.use((req, res, next) => {
+ if (
+ req.path.endsWith('.js') ||
+ req.path.endsWith('.css') ||
+ req.path.endsWith('.html')
+ ) {
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ res.setHeader('Pragma', 'no-cache');
+ res.setHeader('Expires', '0');
+ }
+
+ next();
+});
+
+app.use(express.static(publicDir, {
+ etag: false,
+ lastModified: false,
+}));
+
+app.get('*', (_req, res) => {
+ res.sendFile(resolve(publicDir, 'index.html'));
+});
+
+await initDb();
+
+try {
+ await new SyncService().syncFromDms();
+} catch (err) {
+ console.warn('Initial DMS sync failed. The app still starts:', err);
+}
+
+app.listen(config.port, () => {
+ console.log(`mailadmin listening on ${config.port} for ${config.nodeName}`);
+});
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/.env.example
+================================================================================
+NODE_ENV=production
+PORT=3000
+PUBLIC_DIR=/app/frontend
+DATABASE_URL=postgres://mailadmin:change-me@mailadmin-db:5432/mailadmin
+JWT_SECRET=change-this-long-random-secret
+COOKIE_SECURE=true
+
+# Initial admin is created if it does not exist.
+ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD=ChangeMe123!
+
+# This node identity. Run the same app on node2 with NODE_NAME=node2.
+NODE_NAME=node1
+NODE_HOSTNAME=node1.email-srvr.com
+
+# DMS integration
+DMS_CONTAINER=mailserver
+# Optional: if present on host/container, add/del/sync use this script instead of direct docker exec.
+MANAGE_MAIL_USER_SCRIPT=/opt/email-amazon/basic_setup/manage_mail_user.sh
+MAILDATA_PATH=/mail-data
+
+# AWS / DynamoDB
+AWS_REGION=us-east-2
+DYNAMODB_RULES_TABLE=email-rules
+DYNAMODB_BLOCKED_TABLE=email-blocked-senders
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/Dockerfile
+================================================================================
+FROM node:22-bookworm AS build
+WORKDIR /app/backend
+COPY backend/package*.json ./
+RUN npm install
+COPY backend ./
+RUN npm run build
+
+FROM node:22-bookworm-slim
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends docker.io awscli jq bash coreutils ca-certificates && rm -rf /var/lib/apt/lists/*
+COPY --from=build /app/backend/node_modules ./backend/node_modules
+COPY --from=build /app/backend/dist ./backend/dist
+COPY backend/migrations ./backend/migrations
+COPY frontend ./frontend
+WORKDIR /app/backend
+ENV NODE_ENV=production PUBLIC_DIR=/app/frontend
+CMD ["node", "dist/server.js"]
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/package.json
+================================================================================
+{
+ "name": "mailadmin-backend",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/server.ts",
+ "build": "tsc",
+ "start": "node dist/server.js"
+ },
+ "dependencies": {
+ "@aws-sdk/client-dynamodb": "^3.731.1",
+ "@aws-sdk/lib-dynamodb": "^3.731.1",
+ "bcryptjs": "^2.4.3",
+ "cookie-parser": "^1.4.7",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "jsonwebtoken": "^9.0.2",
+ "pg": "^8.13.1",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@types/bcryptjs": "^2.4.6",
+ "@types/cookie-parser": "^1.4.8",
+ "@types/cors": "^2.8.17",
+ "@types/express": "^5.0.0",
+ "@types/jsonwebtoken": "^9.0.7",
+ "@types/node": "^22.10.6",
+ "@types/pg": "^8.11.10",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3"
+ }
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/backend/tsconfig.json
+================================================================================
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts"]
+}
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/frontend/app.js
+================================================================================
+const app = document.getElementById('app');
+const state = { user: null, domains: [], selectedDomain: null, mailboxes: [], audit: [], message: '', error: '' };
+
+async function api(path, options = {}) {
+ const res = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+}
+
+function bytes(n) {
+ n = Number(n || 0);
+ if (n <= 0) return '0 B';
+ const u = ['B','KB','MB','GB','TB']; let i = 0;
+ while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
+ return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
+}
+
+function percent(n) {
+ if (n === null || n === undefined || Number.isNaN(Number(n))) return null;
+ const value = Number(n);
+ if (value < 1 && value > 0) return value.toFixed(1);
+ return value.toFixed(0);
+}
+
+function usageInfo(m) {
+ const used = Number(m.used_bytes || 0);
+ const limit = m.quota_bytes === null || m.quota_bytes === undefined ? null : Number(m.quota_bytes);
+ const calculated = limit && limit > 0 ? (used / limit) * 100 : null;
+ const rawPct = m.quota_percent === null || m.quota_percent === undefined ? calculated : Number(m.quota_percent);
+ const pct = rawPct === null || Number.isNaN(rawPct) ? null : Math.max(0, Math.min(100, rawPct));
+
+ return {
+ used,
+ limit,
+ pct,
+ label: limit ? `${bytes(used)} / ${bytes(limit)} (${percent(pct)}%)` : `${bytes(used)} / unlimited`,
+ };
+}
+
+function esc(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
+
+async function init() {
+ try { state.user = await api('/api/auth/me'); await loadDomains(true); }
+ catch { state.user = null; }
+ render();
+}
+
+async function loadDomains(resync = false) {
+ state.domains = await api(`/api/domains${resync ? '?resync=true' : ''}`);
+ if (!state.selectedDomain && state.domains.length) state.selectedDomain = state.domains[0].domain;
+ if (state.selectedDomain) await loadMailboxes(true);
+}
+
+async function loadMailboxes(refreshQuota = false) {
+ if (!state.selectedDomain) {
+ state.mailboxes = [];
+ return;
+ }
+ state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}${refreshQuota ? '&refreshQuota=true' : ''}`);
+}
+
+async function loadAudit() {
+ state.audit = await api('/api/audit');
+ renderAuditModal();
+}
+
+function renderUsage(m) {
+ const usage = usageInfo(m);
+ const barWidth = usage.pct === null ? 0 : usage.pct;
+ return `
+
+
Disk Usage: ${esc(usage.label)}
+
+
+ ${m.usage_scanned_at ? `quota checked ${new Date(m.usage_scanned_at).toLocaleString()}` : 'quota not checked yet'}
+ ${m.message_count !== null && m.message_count !== undefined ? ` · ${Number(m.message_count)} messages` : ''}
+
+
`;
+}
+
+function render() {
+ if (!state.user) return renderLogin();
+ app.innerHTML = `
+
+
+ ${state.error ? `
${esc(state.error)}
` : ''}
+ ${state.message ? `
${esc(state.message)}
` : ''}
+
+
+ Domains on this node
+ Domains are discovered dynamically from DMS accounts.
+
+ ${state.domains.map(d => `
+
+ ${esc(d.domain)}
+ ${d.active_mailboxes || 0} inboxes · ${bytes(d.used_bytes)}
+ ${esc(d.current_node)} ${esc(d.status)}
+
`).join('') || '
No domains found yet.
'}
+
+
+
+
+
${esc(state.selectedDomain || 'Mailboxes')}
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
+
+
+
+ | Email | Status | Usage | Updated | Actions |
+
+ ${state.mailboxes.map(m => `
+
+ | ${esc(m.email_address)} ${esc(m.node_name)} |
+ ${esc(m.status)} |
+ ${renderUsage(m)} |
+ ${new Date(m.updated_at).toLocaleString()} |
+
+
+
+
+
+ |
+
`).join('') || '| No mailboxes for this domain. |
'}
+
+
+
+
+
`;
+
+ document.getElementById('logoutBtn').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); state.user = null; render(); };
+ document.getElementById('resyncBtn').onclick = guard(async () => { await api('/api/domains/resync', { method:'POST' }); await loadDomains(false); state.message = 'DMS sync completed.'; render(); });
+ document.getElementById('auditBtn').onclick = guard(loadAudit);
+ document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal;
+ document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(true); render(); }));
+ document.querySelectorAll('[data-delete]').forEach(el => el.onclick = () => renderDeleteModal(el.dataset.delete));
+ document.querySelectorAll('[data-password]').forEach(el => el.onclick = () => renderPasswordModal(el.dataset.password));
+ document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules));
+ document.querySelectorAll('[data-blocks]').forEach(el => el.onclick = () => renderBlocklistModal(el.dataset.blocks));
+}
+
+function renderLogin() {
+ app.innerHTML = ``;
+ document.getElementById('loginForm').onsubmit = guard(async e => {
+ e.preventDefault(); const f = new FormData(e.target);
+ state.user = await api('/api/auth/login', { method:'POST', body: JSON.stringify({ email: f.get('email'), password: f.get('password') }) });
+ await loadDomains(true); render();
+ });
+}
+
+function modal(html) {
+ const div = document.createElement('div');
+ div.className = 'modal-backdrop';
+ div.innerHTML = ``;
+ document.body.appendChild(div);
+ div.querySelector('[data-close]').onclick = () => div.remove();
+ div.onclick = e => { if (e.target === div) div.remove(); };
+ return div;
+}
+
+function renderCreateMailboxModal() {
+ const domain = state.selectedDomain || '';
+
+ const d = modal(`
+ New mailbox
+
+
+ `);
+
+ const emailInput = d.querySelector('#createEmail');
+ const passwordInput = d.querySelector('#createPassword');
+ const submitButton = d.querySelector('#createMailboxSubmit');
+
+ emailInput.focus();
+
+ // Cursor before the @domain, so you can directly type "test".
+ try {
+ emailInput.setSelectionRange(0, 0);
+ } catch {
+ // Some browsers do not allow selection on email inputs.
+ }
+
+ const createMailbox = guard(async () => {
+ const email = String(emailInput.value || '').trim().toLowerCase();
+ const password = String(passwordInput.value || '');
+
+ if (!email || !email.includes('@')) {
+ throw new Error('Please enter a valid email address.');
+ }
+
+ if (!email.endsWith(`@${domain}`)) {
+ throw new Error(`Mailbox must belong to ${domain}.`);
+ }
+
+ if (password.length < 8) {
+ throw new Error('Password must have at least 8 characters.');
+ }
+
+ submitButton.disabled = true;
+ submitButton.textContent = 'Creating...';
+
+ await api('/api/mailboxes', {
+ method: 'POST',
+ body: JSON.stringify({
+ email,
+ password,
+ }),
+ });
+
+ d.remove();
+
+ state.message = `Mailbox created: ${email}`;
+
+ await loadDomains(false);
+ await loadMailboxes(true);
+
+ render();
+ });
+
+ submitButton.onclick = createMailbox;
+
+ passwordInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ createMailbox();
+ }
+ });
+
+ emailInput.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ passwordInput.focus();
+ }
+ });
+}
+
+function renderDeleteModal(email) {
+ const d = modal(`Delete mailbox
Delete ${esc(email)} from DMS?
`);
+ d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(true); render(); });
+}
+
+function renderPasswordModal(email) {
+ const d = modal(`Reset password
`);
+ d.querySelector('#pwForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/password`, { method:'POST', body: JSON.stringify({ password:f.get('password') }) }); d.remove(); state.message = `Password updated for ${email}.`; render(); });
+}
+
+async function renderRulesModal(email) {
+ const rules = await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`);
+ const d = modal(`Rules for ${esc(email)}
`);
+ d.querySelector('#rulesForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`, { method:'PUT', body: JSON.stringify({ ooo_active: !!f.get('ooo_active'), ooo_message:f.get('ooo_message'), forwards:String(f.get('forwards')||'').split(/\n|,/).map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
+}
+
+async function renderBlocklistModal(email) {
+ const block = await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`);
+ const d = modal(`Blocklist for ${esc(email)}
`);
+ d.querySelector('#blockForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { method:'PUT', body: JSON.stringify({ blocked_patterns:String(f.get('blocked_patterns')||'').split('\n').map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
+}
+
+function renderAuditModal() {
+ modal(`Audit Log
| Time | Actor | Action | Target |
${state.audit.map(a => `| ${new Date(a.created_at).toLocaleString()} | ${esc(a.actor_email)} | ${esc(a.action)} | ${esc(a.target_id)} |
`).join('')}
`);
+}
+
+function guard(fn) { return async function(...args) { try { state.error=''; state.message=''; await fn.apply(this,args); } catch(e) { state.error = e.message; render(); } }; }
+
+init();
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/frontend/index.html
+================================================================================
+
+
+
+
+
+ MailAdmin
+
+
+
+
+
+
+
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/frontend/styles.css
+================================================================================
+:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; --ok:#059669; }
+* { box-sizing: border-box; }
+body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--text); }
+button, input, textarea, select { font: inherit; }
+button { border: 0; border-radius: 10px; padding: 10px 14px; background: var(--accent); color: white; cursor: pointer; }
+button.secondary { background: #eef2ff; color: #1e3a8a; }
+button.danger { background: var(--danger); }
+button.ghost { background: transparent; color: var(--accent); }
+input, textarea, select { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: white; }
+textarea { min-height: 110px; }
+.header { display:flex; justify-content:space-between; align-items:center; padding: 18px 28px; background: white; border-bottom:1px solid var(--line); position: sticky; top: 0; z-index: 5; }
+.brand { font-weight: 800; font-size: 20px; }
+.container { max-width: 1280px; margin: 0 auto; padding: 24px; }
+.grid { display:grid; grid-template-columns: 320px 1fr; gap: 20px; align-items:start; }
+.card { background: var(--card); border:1px solid var(--line); border-radius: 18px; padding: 18px; box-shadow: 0 10px 25px rgba(15,23,42,.04); }
+.card h2 { margin:0 0 14px; font-size: 18px; }
+.list { display:flex; flex-direction:column; gap: 8px; }
+.list-item { border:1px solid var(--line); border-radius: 14px; padding: 12px; background:#fff; cursor:pointer; }
+.list-item.active { border-color: var(--accent); background:#eff6ff; }
+.muted { color: var(--muted); font-size: 13px; }
+.row { display:flex; gap: 10px; align-items:center; }
+.row > * { flex: 1; }
+.table { width:100%; border-collapse: collapse; }
+.table th, .table td { text-align:left; padding: 10px; border-bottom:1px solid var(--line); vertical-align: top; }
+.table th { font-size:12px; color:var(--muted); text-transform: uppercase; letter-spacing:.04em; }
+.pill { display:inline-flex; align-items:center; padding: 3px 8px; border-radius: 999px; background:#f3f4f6; font-size:12px; }
+.actions { display:flex; flex-wrap:wrap; gap:8px; }
+.login { min-height: 100vh; display:grid; place-items:center; padding:24px; }
+.login .card { width:min(420px, 100%); }
+.error { color: var(--danger); margin: 10px 0; }
+.success { color: #047857; margin: 10px 0; }
+.modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.35); display:grid; place-items:center; padding: 20px; z-index: 10; }
+.modal { width:min(720px, 100%); max-height: 90vh; overflow:auto; }
+.form-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
+.usage-cell { min-width: 220px; }
+.usage-label { font-size: 13px; font-weight: 650; margin-bottom: 6px; }
+.usage-bar { width: 100%; height: 10px; border-radius: 999px; background: #e5e7eb; overflow: hidden; border: 1px solid #d1d5db; }
+.usage-bar-fill { height: 100%; border-radius: 999px; background: var(--ok); transition: width .25s ease; }
+@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } }
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/.env.example
+================================================================================
+MAILADMIN_DB_PASSWORD=change-me
+MAILADMIN_JWT_SECRET=replace-with-long-random-secret
+MAILADMIN_ADMIN_EMAIL=admin@bayarea-cc.com
+MAILADMIN_ADMIN_PASSWORD=ChangeMe123!
+NODE_NAME=node1
+NODE_HOSTNAME=node1.email-srvr.com
+DMS_CONTAINER=mailserver
+AWS_REGION=us-east-2
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+
+
+================================================================================
+FILE: /home/aknuth/git/mailadmin/docker-compose.yml
+================================================================================
+services:
+ mailadmin-db:
+ image: postgres:16
+ container_name: mailadmin-db
+ environment:
+ POSTGRES_DB: mailadmin
+ POSTGRES_USER: mailadmin
+ POSTGRES_PASSWORD: ${MAILADMIN_DB_PASSWORD}
+ volumes:
+ - ./data/postgres:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U mailadmin -d mailadmin"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+ start_period: 10s
+ restart: unless-stopped
+ networks:
+ - mailadmin_network
+
+ mailadmin:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ container_name: mailadmin
+ restart: unless-stopped
+ depends_on:
+ mailadmin-db:
+ condition: service_healthy
+ environment:
+ NODE_ENV: production
+ PORT: 3000
+ PUBLIC_DIR: /app/frontend
+ DATABASE_URL: postgres://mailadmin:${MAILADMIN_DB_PASSWORD:-change-me}@mailadmin-db:5432/mailadmin
+ JWT_SECRET: ${MAILADMIN_JWT_SECRET:-change-this-long-random-secret}
+ COOKIE_SECURE: "true"
+ ADMIN_EMAIL: ${MAILADMIN_ADMIN_EMAIL:-admin@example.com}
+ ADMIN_PASSWORD: ${MAILADMIN_ADMIN_PASSWORD:-ChangeMe123!}
+ NODE_NAME: ${NODE_NAME:-node1}
+ NODE_HOSTNAME: ${NODE_HOSTNAME:-node1.email-srvr.com}
+ DMS_CONTAINER: ${DMS_CONTAINER:-mailserver}
+ MANAGE_MAIL_USER_SCRIPT: /host/email-amazon/basic_setup/manage_mail_user.sh
+ MAILDATA_PATH: /mail-data
+ AWS_REGION: ${AWS_REGION:-us-east-2}
+ AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
+ AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
+ DYNAMODB_RULES_TABLE: ${DYNAMODB_RULES_TABLE:-email-rules}
+ DYNAMODB_BLOCKED_TABLE: ${DYNAMODB_BLOCKED_TABLE:-email-blocked-senders}
+ volumes:
+ # Needed so backend can call docker exec mailserver.
+ - /var/run/docker.sock:/var/run/docker.sock
+ # Adjust this path to your repository root on the host.
+ - /home/aknuth/git/email-amazon:/host/email-amazon:ro
+ # Adjust this path to your DMS mail-data directory.
+ - /home/aknuth/git/email-amazon/DMS/docker-data/dms/mail-data:/mail-data:ro
+ networks:
+ - mailadmin_network
+
+networks:
+ mailadmin_network:
+ external: true
+