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 = ` -
-
MailAdmin
${esc(state.user.email)} · ${esc(state.user.role)}
-
- - - -
-
-
- ${state.error ? `
${esc(state.error)}
` : ''} - ${state.message ? `
${esc(state.message)}
` : ''} -
-
-

Domains on this node

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

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

Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
-
-
- - - - ${state.mailboxes.map(m => ` - - - - - - - `).join('') || ''} - -
EmailStatusUsageUpdatedActions
${esc(m.email_address)}
${esc(m.node_name)}
${esc(m.status)}${renderUsage(m)}${new Date(m.updated_at).toLocaleString()}
- - - - -
No mailboxes for this domain.
-
-
-
`; - - document.getElementById('logoutBtn').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); state.user = null; render(); }; - document.getElementById('resyncBtn').onclick = guard(async () => { await api('/api/domains/resync', { method:'POST' }); await loadDomains(false); state.message = 'DMS sync completed.'; render(); }); - document.getElementById('auditBtn').onclick = guard(loadAudit); - document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal; - document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(true); render(); })); - document.querySelectorAll('[data-delete]').forEach(el => el.onclick = () => renderDeleteModal(el.dataset.delete)); - document.querySelectorAll('[data-password]').forEach(el => el.onclick = () => renderPasswordModal(el.dataset.password)); - document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules)); - document.querySelectorAll('[data-blocks]').forEach(el => el.onclick = () => renderBlocklistModal(el.dataset.blocks)); -} - -function renderLogin() { - app.innerHTML = `

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

${state.audit.map(a => ``).join('')}
TimeActorActionTarget
${new Date(a.created_at).toLocaleString()}${esc(a.actor_email)}${esc(a.action)}${esc(a.target_id)}
`); -} - -function guard(fn) { return async function(...args) { try { state.error=''; state.message=''; await fn.apply(this,args); } catch(e) { state.error = e.message; render(); } }; } - -init(); \ 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 */} +
+
+
+

MailAdmin

+

+ {user.email} · {user.role} +

+
+
+ + + +
+
+
+ + {/* 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.'} +

+
+ ) : ( +
+ + + + + + + + + + + + {mailboxes.map((m) => ( + + + + + + {/* Actions: single row, no wrapping. Punkt 3 deiner Liste. */} + + + ))} + +
EmailStatusUsageUpdatedActions
+
{m.email_address}
+
{m.node_name}
+
+ + {m.status} + + + {new Date(m.updated_at).toLocaleString()} + +
+ + + +
+
+
+ )} +
+
+
+ + {/* 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 && ( +
+ + + + + + + + + + + {rows.map((a) => ( + + + + + + + ))} + +
TimeActorActionTarget
+ {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) => ( +
+
+
+ +
+

{pattern}

+
+ +
+ ))} +
+ )} +
+ +
+ +
+
+ ); +}; + +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) => ( +
+
+
+ +
+

{email}

+
+ +
+ ))} +
+ )} +
+ +
+ +
+
+ ); +}; + +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 ( +
+
+ +

{message}

+
+
+ ); +}; + +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

+
+ +
+
+ +
+ + setEmail(e.target.value)} + className="input-field pl-10" + required + autoFocus + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="input-field pl-10" + required + /> +
+
+ + {error &&

{error}

} + + +
+
+
+ ); +}; + +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'} +

+
+
+ +
+ +
+ +



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

Blocklist for ${esc(email)}



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

Audit Log

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