This commit is contained in:
2026-04-27 15:24:23 -05:00
parent 32a00b3706
commit c4acdb2a66
28 changed files with 3575 additions and 358 deletions

384
frontend/src/App.jsx Normal file
View File

@@ -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 <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
}
if (!user) {
return (
<>
<Login onLogin={setUser} />
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
</>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">MailAdmin</h1>
<p className="text-xs text-gray-500">
{user.email} · <span className="font-medium">{user.role}</span>
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setShowAudit(true)} className="btn-secondary">
<FiList className="w-4 h-4 mr-2" />
Audit Log
</button>
<button onClick={handleResync} className="btn-secondary">
<FiRefreshCw className="w-4 h-4 mr-2" />
DMS Resync
</button>
<button onClick={handleLogout} className="btn-ghost">
<FiLogOut className="w-4 h-4 mr-2" />
Logout
</button>
</div>
</div>
</header>
{/* Main */}
<main className="max-w-7xl mx-auto px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 items-start">
{/* Domains */}
<section className="card">
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
<p className="text-xs text-gray-500 mt-1 mb-4">
Domains are discovered dynamically from DMS accounts.
</p>
{domains.length === 0 ? (
<p className="text-sm text-gray-400">No domains found yet.</p>
) : (
<div className="flex flex-col gap-2">
{domains.map((d) => {
const active = d.domain === selectedDomain;
return (
<button
key={d.domain}
onClick={() => selectDomain(d.domain)}
className={`text-left p-3 rounded-lg border transition-colors ${
active
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-semibold text-gray-900">{d.domain}</div>
<div className="text-xs text-gray-500 mt-0.5">
{d.active_mailboxes || 0} inboxes · {formatBytes(d.used_bytes)}
</div>
<div className="flex flex-wrap gap-1 mt-2">
<span className="pill">{d.current_node || d.node_name}</span>
<span className={d.status === 'active' ? 'pill-success' : 'pill'}>
{d.status}
</span>
</div>
</button>
);
})}
</div>
)}
</section>
{/* Mailboxes */}
<section className="card relative">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<h2 className="text-base font-semibold text-gray-900">
{selectedDomain || 'Mailboxes'}
</h2>
<p className="text-xs text-gray-500 mt-1">
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
</p>
</div>
<button
onClick={() => setShowNew(true)}
disabled={!selectedDomain}
className="btn-primary"
>
<FiPlus className="w-4 h-4 mr-2" />
New mailbox
</button>
</div>
{mailboxes.length === 0 ? (
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
<FiInbox className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No mailboxes for this domain</p>
<p className="text-sm text-gray-500 mt-1">
{selectedDomain ? 'Click "New mailbox" to create the first one.' : 'Select a domain first.'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
<th className="py-3 pr-4 font-semibold">Email</th>
<th className="py-3 pr-4 font-semibold">Status</th>
<th className="py-3 pr-4 font-semibold">Usage</th>
<th className="py-3 pr-4 font-semibold whitespace-nowrap">Updated</th>
<th className="py-3 pr-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{mailboxes.map((m) => (
<tr key={m.email_address} className="align-top hover:bg-gray-50">
<td className="py-3 pr-4">
<div className="font-semibold text-gray-900">{m.email_address}</div>
<div className="text-xs text-gray-500">{m.node_name}</div>
</td>
<td className="py-3 pr-4">
<span className={m.status === 'active' ? 'pill-success' : 'pill'}>
{m.status}
</span>
</td>
<td className="py-3 pr-4"><UsageBar mailbox={m} /></td>
<td className="py-3 pr-4 text-xs text-gray-500 whitespace-nowrap">
{new Date(m.updated_at).toLocaleString()}
</td>
{/* Actions: single row, no wrapping. Punkt 3 deiner Liste. */}
<td className="py-3 pr-2">
<div className="flex flex-row flex-nowrap items-center gap-2">
<button
onClick={() => setSettingsTarget({ email: m.email_address, tab: 'fwd' })}
className="btn-secondary"
title="Forwarding & Auto-Reply"
>
<FiSettings className="w-3.5 h-3.5 mr-1.5" />
Settings
</button>
<button
onClick={() => setPwTarget(m.email_address)}
className="btn-secondary"
title="Reset password"
>
<FiKey className="w-3.5 h-3.5 mr-1.5" />
Password
</button>
<button
onClick={() => setDeleteTarget(m.email_address)}
className="btn-danger"
title="Delete mailbox"
>
<FiTrash2 className="w-3.5 h-3.5 mr-1.5" />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
{/* Modals */}
<MailboxSettingsModal
open={!!settingsTarget}
email={settingsTarget?.email}
initialTab={settingsTarget?.tab}
onClose={() => setSettingsTarget(null)}
onToast={showToast}
/>
<NewMailboxModal
open={showNew}
domain={selectedDomain}
onClose={() => setShowNew(false)}
onCreated={handleMailboxCreated}
onToast={showToast}
/>
<PasswordResetModal
open={!!pwTarget}
email={pwTarget}
onClose={() => setPwTarget(null)}
onToast={showToast}
/>
<ConfirmDialog
open={!!deleteTarget}
title="Delete mailbox"
message={
deleteTarget
? `Delete ${deleteTarget} from DMS? This cannot be undone.`
: ''
}
confirmLabel="Delete"
danger
onConfirm={handleDelete}
onClose={() => setDeleteTarget(null)}
/>
<AuditLogModal
open={showAudit}
onClose={() => setShowAudit(false)}
onToast={showToast}
/>
{/* Global blocking overlay (Punkt 2 deiner Liste) */}
{busyMessage && <LoadingOverlay message={busyMessage} fullscreen />}
{/* Toast */}
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
</div>
);
}
export default App;

View File

@@ -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 (
<Modal open={open} onClose={onClose} title="Audit Log" subtitle="Most recent 200 actions" size="lg">
<div className="relative min-h-[300px]">
{loading && <LoadingOverlay message="Loading..." />}
{!loading && rows.length === 0 && (
<p className="text-sm text-gray-500 text-center py-12">No audit entries yet.</p>
)}
{!loading && rows.length > 0 && (
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
<tr>
<th className="py-2 pr-4 font-semibold">Time</th>
<th className="py-2 pr-4 font-semibold">Actor</th>
<th className="py-2 pr-4 font-semibold">Action</th>
<th className="py-2 pr-4 font-semibold">Target</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((a) => (
<tr key={a.id} className="hover:bg-gray-50">
<td className="py-2 pr-4 text-gray-500 whitespace-nowrap">
{new Date(a.created_at).toLocaleString()}
</td>
<td className="py-2 pr-4 text-gray-900">{a.actor_email}</td>
<td className="py-2 pr-4 font-mono text-xs text-gray-700">{a.action}</td>
<td className="py-2 pr-4 text-gray-700">{a.target_id}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Modal>
);
};
export default AuditLogModal;

View File

@@ -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 (
<div className="space-y-6">
<div>
<label htmlFor="block-pattern" className="block text-sm font-semibold text-gray-700 mb-2">
Block Sender Pattern(s)
</label>
<div className="flex gap-2">
<div className="flex-1">
<input
id="block-pattern"
type="text"
value={newPattern}
onChange={(e) => {
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 && (
<p className={`mt-1 text-sm ${inputMessage.kind === 'success' ? 'text-green-600' : 'text-red-600'}`}>
{inputMessage.text}
</p>
)}
</div>
<button onClick={handleAdd} className="btn-danger whitespace-nowrap px-4 py-2">
<FiPlus className="w-4 h-4 mr-2" />
Block
</button>
</div>
<p className="mt-2 text-xs text-gray-500">
Paste a comma-separated list to add multiple entries at once. Supports wildcards (*).
</p>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-semibold text-gray-700">
Blocked Patterns ({patterns.length})
</label>
{patterns.length > 0 && (
<button
onClick={() => setPatterns([])}
className="text-xs text-red-600 hover:text-red-800 hover:underline"
>
Clear all
</button>
)}
</div>
{patterns.length === 0 ? (
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
<FiSlash className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No blocked senders configured</p>
<p className="text-sm text-gray-500 mt-1">Add a pattern above to block incoming emails</p>
</div>
) : (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 custom-scrollbar">
{patterns.map((pattern, idx) => (
<div
key={`${pattern}-${idx}`}
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-red-300 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 bg-red-100 rounded-full">
<FiShield className="w-4 h-4 text-red-600" />
</div>
<p className="font-medium text-gray-900 font-mono text-sm">{pattern}</p>
</div>
<button
onClick={() => handleRemove(pattern)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Remove block"
>
<FiTrash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button onClick={handleSave} disabled={isSaving} className="btn-primary">
{isSaving ? 'Saving...' : 'Save Block List'}
</button>
</div>
</div>
);
};
export default BlockedSenders;

View File

@@ -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 (
<Modal open={open} onClose={onClose} title={title} size="sm">
<div className="flex items-start gap-3">
{danger && (
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-100 flex-shrink-0">
<FiAlertTriangle className="w-5 h-5 text-red-600" />
</div>
)}
<p className="text-sm text-gray-700 flex-1 pt-1">{message}</p>
</div>
<div className="flex justify-end gap-2 pt-4 mt-4 border-t border-gray-200">
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>Cancel</button>
<button
onClick={handleConfirm}
disabled={busy}
className={danger ? 'btn-danger px-4 py-2' : 'btn-primary'}
>
{busy ? 'Working...' : confirmLabel}
</button>
</div>
</Modal>
);
};
export default ConfirmDialog;

View File

@@ -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 (
<div className="space-y-6">
<div>
<label htmlFor="fwd-email" className="block text-sm font-semibold text-gray-700 mb-2">
Add Forward Address
</label>
<div className="flex gap-2">
<div className="flex-1">
<input
id="fwd-email"
type="email"
value={newEmail}
onChange={(e) => { 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 && <p className="mt-1 text-sm text-red-600">{emailError}</p>}
</div>
<button onClick={handleAdd} className="btn-primary whitespace-nowrap">
<FiPlus className="w-4 h-4 mr-2" />
Add
</button>
</div>
<p className="mt-2 text-xs text-gray-500">
All emails sent to this mailbox will also be delivered to the addresses below.
</p>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-semibold text-gray-700">
Forward Addresses ({forwards.length})
</label>
</div>
{forwards.length === 0 ? (
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
<FiMail className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No forward addresses configured</p>
<p className="text-sm text-gray-500 mt-1">Add an email address above to get started</p>
</div>
) : (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 custom-scrollbar">
{forwards.map((email, idx) => (
<div
key={`${email}-${idx}`}
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-primary-300 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 bg-primary-100 rounded-full">
<FiCheck className="w-4 h-4 text-primary-600" />
</div>
<p className="font-medium text-gray-900">{email}</p>
</div>
<button
onClick={() => handleRemove(email)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Remove forward"
>
<FiTrash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button onClick={handleSave} disabled={isSaving} className="btn-primary">
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
);
};
export default Forwarding;

View File

@@ -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 (
<div className={`${positioning} bg-white/70 backdrop-blur-sm flex items-center justify-center`}>
<div className="flex flex-col items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-200 px-6 py-5">
<FiLoader className="w-7 h-7 text-primary-600 animate-spin" />
<p className="text-sm font-medium text-gray-700">{message}</p>
</div>
</div>
);
};
export default LoadingOverlay;

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-primary-50 p-6">
<div className="card w-full max-w-md">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">MailAdmin</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to manage your mail server</p>
</div>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
<div className="relative">
<FiMail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input-field pl-10"
required
autoFocus
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Password</label>
<div className="relative">
<FiLock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-field pl-10"
required
/>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="btn-primary w-full">
{busy ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
};
export default Login;

View File

@@ -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 (
<Modal
open={open}
onClose={onClose}
title={email || 'Mailbox settings'}
subtitle="Forwarding, auto-reply and blocklist"
size="md"
>
<div className="relative min-h-[400px]">
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex gap-1">
{TABS.map((t) => {
const Icon = t.icon;
const isActive = activeTab === t.id;
return (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
</div>
{/* Content */}
{loading || !rule || !blocklist ? (
<div className="py-16 flex items-center justify-center">
<LoadingOverlay message="Loading settings..." />
</div>
) : (
<>
{activeTab === 'fwd' && <Forwarding rule={rule} onSave={saveRule} />}
{activeTab === 'ooo' && <OutOfOffice rule={rule} onSave={saveRule} />}
{activeTab === 'block' && <BlockedSenders blocklist={blocklist} onSave={saveBlocklist} />}
</>
)}
</div>
</Modal>
);
};
export default MailboxSettingsModal;

View File

@@ -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 (
<div
className="fixed inset-0 z-30 bg-gray-900/40 backdrop-blur-sm flex items-start justify-center p-4 sm:p-8 overflow-y-auto"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className={`bg-white rounded-xl shadow-xl border border-gray-200 w-full ${widths[size]} my-8`}>
<div className="flex items-start justify-between px-6 py-4 border-b border-gray-100">
<div>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-700 p-1 rounded-lg hover:bg-gray-100 transition-colors"
aria-label="Close"
>
<FiX className="w-5 h-5" />
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
};
export default Modal;

View File

@@ -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 (
<Modal open={open} onClose={onClose} title="New mailbox" subtitle={domain} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
<input
ref={localPartRef}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('new-mb-pw')?.focus(); } }}
className="input-field"
autoComplete="off"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Password</label>
<input
id="new-mb-pw"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
className="input-field"
minLength={8}
autoComplete="new-password"
required
/>
<p className="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2 border-t border-gray-200">
<button onClick={onClose} className="btn-secondary px-4 py-2">Cancel</button>
<button onClick={submit} disabled={busy} className="btn-primary">
{busy ? 'Creating...' : 'Create'}
</button>
</div>
</div>
</Modal>
);
};
export default NewMailboxModal;

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-3">
<FiCalendar className="w-5 h-5 text-gray-600" />
<div>
<h3 className="font-semibold text-gray-900">Out of Office Status</h3>
<p className="text-sm text-gray-600">
{isActive ? 'Auto-reply is currently active' : 'Auto-reply is currently inactive'}
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isActive ? 'bg-primary-600' : 'bg-gray-300'
}`}
aria-pressed={isActive}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isActive ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div>
<label htmlFor="ooo-message" className="block text-sm font-semibold text-gray-700 mb-2">
Auto-Reply Message
</label>
<textarea
id="ooo-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={8}
placeholder="I am currently out of office until [date].&#10;&#10;Best regards,&#10;Your Name"
className="input-field font-mono text-sm resize-none"
disabled={!isActive}
/>
<p className="mt-2 text-xs text-gray-500">
Plain text message that gets returned automatically to senders.
</p>
</div>
{isActive && message && (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Message Preview
</label>
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
<pre className="text-sm text-gray-800 whitespace-pre-wrap font-sans">{message}</pre>
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSaving || (isActive && !message.trim())}
className="btn-primary"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
);
};
export default OutOfOffice;

View File

@@ -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 (
<Modal open={open} onClose={onClose} title="Reset password" subtitle={email} size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">New password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
className="input-field"
minLength={8}
autoFocus
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2 border-t border-gray-200">
<button onClick={onClose} className="btn-secondary px-4 py-2">Cancel</button>
<button onClick={submit} disabled={busy} className="btn-primary">
{busy ? 'Updating...' : 'Update password'}
</button>
</div>
</div>
</Modal>
);
};
export default PasswordResetModal;

View File

@@ -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: <FiCheckCircle className="w-5 h-5 text-green-600" /> },
error: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-800', icon: <FiXCircle className="w-5 h-5 text-red-600" /> },
warning: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-800', icon: <FiAlertCircle className="w-5 h-5 text-amber-600" /> },
};
const s = styles[type] || styles.success;
return (
<div className="fixed top-4 right-4 z-50 animate-slide-in">
<div className={`flex items-start gap-3 ${s.bg} ${s.border} border rounded-lg shadow-lg p-4 min-w-[300px] max-w-md`}>
{s.icon}
<p className={`flex-1 text-sm font-medium ${s.text}`}>{message}</p>
<button onClick={onClose} className={`${s.text} opacity-60 hover:opacity-100`}>
<FiX className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default Toast;

View File

@@ -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 (
<div className="min-w-[200px]">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Disk Usage: {label}</div>
<div className="w-full h-2 rounded-full bg-gray-200 overflow-hidden">
<div
className={`h-full ${fillColor} transition-all duration-300`}
style={{ width: `${pct ?? 0}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{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` : ''}
</div>
</div>
);
};
export default UsageBar;

55
frontend/src/index.css Normal file
View File

@@ -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; }
}

10
frontend/src/main.jsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -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,
};