react
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
frontend.old
|
||||||
@@ -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
|
WORKDIR /app/backend
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY backend ./
|
COPY backend ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
# Output: /app/backend/dist + node_modules
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 3: Slim runtime image
|
||||||
|
# ============================================================
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
WORKDIR /app
|
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
|
RUN apt-get update \
|
||||||
COPY --from=build /app/backend/dist ./backend/dist
|
&& apt-get install -y --no-install-recommends \
|
||||||
COPY backend/migrations ./backend/migrations
|
docker.io awscli jq bash coreutils ca-certificates \
|
||||||
COPY frontend ./frontend
|
&& 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
|
WORKDIR /app/backend
|
||||||
ENV NODE_ENV=production PUBLIC_DIR=/app/frontend
|
ENV NODE_ENV=production PUBLIC_DIR=/app/frontend
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "dist/server.js"]
|
||||||
|
|||||||
10
frontend/.gitignore
vendored
Normal file
10
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
302
frontend/app.js
302
frontend/app.js
@@ -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 `
|
|
||||||
<div class="usage-cell">
|
|
||||||
<div class="usage-label">Disk Usage: ${esc(usage.label)}</div>
|
|
||||||
<div class="usage-bar" aria-label="Disk Usage ${esc(usage.label)}">
|
|
||||||
<div class="usage-bar-fill" style="width:${barWidth}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="muted">
|
|
||||||
${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` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!state.user) return renderLogin();
|
|
||||||
app.innerHTML = `
|
|
||||||
<div class="header">
|
|
||||||
<div><div class="brand">MailAdmin</div><div class="muted">${esc(state.user.email)} · ${esc(state.user.role)}</div></div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="secondary" id="auditBtn">Audit Log</button>
|
|
||||||
<button class="secondary" id="resyncBtn">DMS Resync</button>
|
|
||||||
<button class="ghost" id="logoutBtn">Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}
|
|
||||||
${state.message ? `<div class="success">${esc(state.message)}</div>` : ''}
|
|
||||||
<div class="grid">
|
|
||||||
<section class="card">
|
|
||||||
<h2>Domains on this node</h2>
|
|
||||||
<div class="muted" style="margin-bottom:12px">Domains are discovered dynamically from DMS accounts.</div>
|
|
||||||
<div class="list">
|
|
||||||
${state.domains.map(d => `
|
|
||||||
<div class="list-item ${d.domain === state.selectedDomain ? 'active' : ''}" data-domain="${esc(d.domain)}">
|
|
||||||
<strong>${esc(d.domain)}</strong><br>
|
|
||||||
<span class="muted">${d.active_mailboxes || 0} inboxes · ${bytes(d.used_bytes)}</span><br>
|
|
||||||
<span class="pill">${esc(d.current_node)}</span> <span class="pill">${esc(d.status)}</span>
|
|
||||||
</div>`).join('') || '<div class="muted">No domains found yet.</div>'}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="card">
|
|
||||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:14px">
|
|
||||||
<div><h2 style="margin:0">${esc(state.selectedDomain || 'Mailboxes')}</h2><div class="muted">Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.</div></div>
|
|
||||||
<div class="actions"><button id="newMailboxBtn">New mailbox</button></div>
|
|
||||||
</div>
|
|
||||||
<table class="table">
|
|
||||||
<thead><tr><th>Email</th><th>Status</th><th>Usage</th><th>Updated</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${state.mailboxes.map(m => `
|
|
||||||
<tr>
|
|
||||||
<td><strong>${esc(m.email_address)}</strong><div class="muted">${esc(m.node_name)}</div></td>
|
|
||||||
<td><span class="pill">${esc(m.status)}</span></td>
|
|
||||||
<td>${renderUsage(m)}</td>
|
|
||||||
<td class="muted">${new Date(m.updated_at).toLocaleString()}</td>
|
|
||||||
<td><div class="actions">
|
|
||||||
<button class="secondary" data-rules="${esc(m.email_address)}">Rules</button>
|
|
||||||
<button class="secondary" data-blocks="${esc(m.email_address)}">Blocklist</button>
|
|
||||||
<button class="secondary" data-password="${esc(m.email_address)}">Password</button>
|
|
||||||
<button class="danger" data-delete="${esc(m.email_address)}">Delete</button>
|
|
||||||
</div></td>
|
|
||||||
</tr>`).join('') || '<tr><td colspan="5" class="muted">No mailboxes for this domain.</td></tr>'}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
document.getElementById('logoutBtn').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); state.user = null; render(); };
|
|
||||||
document.getElementById('resyncBtn').onclick = guard(async () => { await api('/api/domains/resync', { method:'POST' }); await loadDomains(false); state.message = 'DMS sync completed.'; render(); });
|
|
||||||
document.getElementById('auditBtn').onclick = guard(loadAudit);
|
|
||||||
document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal;
|
|
||||||
document.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 = `<div class="login"><div class="card"><h2>MailAdmin Login</h2>${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}<form id="loginForm"><label>Email<br><input name="email" type="email" required></label><br><br><label>Password<br><input name="password" type="password" required></label><br><br><button>Login</button></form></div></div>`;
|
|
||||||
document.getElementById('loginForm').onsubmit = guard(async e => {
|
|
||||||
e.preventDefault(); const f = new FormData(e.target);
|
|
||||||
state.user = await api('/api/auth/login', { method:'POST', body: JSON.stringify({ email: f.get('email'), password: f.get('password') }) });
|
|
||||||
await loadDomains(true); render();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function modal(html) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'modal-backdrop';
|
|
||||||
div.innerHTML = `<div class="card modal">${html}<div style="margin-top:14px"><button class="ghost" data-close>Close</button></div></div>`;
|
|
||||||
document.body.appendChild(div);
|
|
||||||
div.querySelector('[data-close]').onclick = () => div.remove();
|
|
||||||
div.onclick = e => { if (e.target === div) div.remove(); };
|
|
||||||
return div;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCreateMailboxModal() {
|
|
||||||
const domain = state.selectedDomain || '';
|
|
||||||
|
|
||||||
const d = modal(`
|
|
||||||
<h2>New mailbox</h2>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input
|
|
||||||
id="createEmail"
|
|
||||||
type="email"
|
|
||||||
value="@${esc(domain)}"
|
|
||||||
placeholder="@${esc(domain)}"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
id="createPassword"
|
|
||||||
type="password"
|
|
||||||
value=""
|
|
||||||
minlength="8"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="button" id="createMailboxSubmit">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
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(`<h2>Delete mailbox</h2><p>Delete <strong>${esc(email)}</strong> from DMS?</p><button class="danger" id="confirmDelete">Delete</button>`);
|
|
||||||
d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(true); render(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPasswordModal(email) {
|
|
||||||
const d = modal(`<h2>Reset password</h2><form id="pwForm"><label>New password<input name="password" type="password" minlength="8" required></label><br><br><button>Update password</button></form>`);
|
|
||||||
d.querySelector('#pwForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/password`, { method:'POST', body: JSON.stringify({ password:f.get('password') }) }); d.remove(); state.message = `Password updated for ${email}.`; render(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderRulesModal(email) {
|
|
||||||
const rules = await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`);
|
|
||||||
const d = modal(`<h2>Rules for ${esc(email)}</h2><form id="rulesForm"><label><input type="checkbox" name="ooo_active" ${rules.ooo_active ? 'checked' : ''} style="width:auto"> Auto reply active</label><br><br><label>Auto reply message<textarea name="ooo_message">${esc(rules.ooo_message || '')}</textarea></label><br><br><label>Forwards, one email per line<textarea name="forwards">${esc((rules.forwards || []).join('\n'))}</textarea></label><br><br><button>Save rules</button></form>`);
|
|
||||||
d.querySelector('#rulesForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`, { method:'PUT', body: JSON.stringify({ ooo_active: !!f.get('ooo_active'), ooo_message:f.get('ooo_message'), forwards:String(f.get('forwards')||'').split(/\n|,/).map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderBlocklistModal(email) {
|
|
||||||
const block = await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`);
|
|
||||||
const d = modal(`<h2>Blocklist for ${esc(email)}</h2><form id="blockForm"><label>Patterns, one per line<br><span class="muted">Examples: spam@example.com, *@bad-domain.com</span><textarea name="blocked_patterns">${esc((block.blocked_patterns || []).join('\n'))}</textarea></label><br><br><button>Save blocklist</button></form>`);
|
|
||||||
d.querySelector('#blockForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { method:'PUT', body: JSON.stringify({ blocked_patterns:String(f.get('blocked_patterns')||'').split('\n').map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAuditModal() {
|
|
||||||
modal(`<h2>Audit Log</h2><table class="table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead><tbody>${state.audit.map(a => `<tr><td class="muted">${new Date(a.created_at).toLocaleString()}</td><td>${esc(a.actor_email)}</td><td>${esc(a.action)}</td><td>${esc(a.target_id)}</td></tr>`).join('')}</tbody></table>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function guard(fn) { return async function(...args) { try { state.error=''; state.message=''; await fn.apply(this,args); } catch(e) { state.error = e.message; render(); } }; }
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MailAdmin</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</head>
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<body>
|
<title>MailAdmin</title>
|
||||||
<div id="app"></div>
|
</head>
|
||||||
<script type="module" src="/app.js"></script>
|
<body>
|
||||||
</body>
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
384
frontend/src/App.jsx
Normal file
384
frontend/src/App.jsx
Normal 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;
|
||||||
65
frontend/src/components/AuditLogModal.jsx
Normal file
65
frontend/src/components/AuditLogModal.jsx
Normal 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;
|
||||||
158
frontend/src/components/BlockedSenders.jsx
Normal file
158
frontend/src/components/BlockedSenders.jsx
Normal 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;
|
||||||
37
frontend/src/components/ConfirmDialog.jsx
Normal file
37
frontend/src/components/ConfirmDialog.jsx
Normal 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;
|
||||||
118
frontend/src/components/Forwarding.jsx
Normal file
118
frontend/src/components/Forwarding.jsx
Normal 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;
|
||||||
23
frontend/src/components/LoadingOverlay.jsx
Normal file
23
frontend/src/components/LoadingOverlay.jsx
Normal 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;
|
||||||
73
frontend/src/components/Login.jsx
Normal file
73
frontend/src/components/Login.jsx
Normal 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;
|
||||||
130
frontend/src/components/MailboxSettingsModal.jsx
Normal file
130
frontend/src/components/MailboxSettingsModal.jsx
Normal 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;
|
||||||
49
frontend/src/components/Modal.jsx
Normal file
49
frontend/src/components/Modal.jsx
Normal 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;
|
||||||
91
frontend/src/components/NewMailboxModal.jsx
Normal file
91
frontend/src/components/NewMailboxModal.jsx
Normal 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;
|
||||||
88
frontend/src/components/OutOfOffice.jsx
Normal file
88
frontend/src/components/OutOfOffice.jsx
Normal 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]. Best regards, 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;
|
||||||
55
frontend/src/components/PasswordResetModal.jsx
Normal file
55
frontend/src/components/PasswordResetModal.jsx
Normal 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;
|
||||||
30
frontend/src/components/Toast.jsx
Normal file
30
frontend/src/components/Toast.jsx
Normal 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;
|
||||||
60
frontend/src/components/UsageBar.jsx
Normal file
60
frontend/src/components/UsageBar.jsx
Normal 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
55
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||||
|
)
|
||||||
71
frontend/src/services/api.js
Normal file
71
frontend/src/services/api.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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; } }
|
|
||||||
29
frontend/tailwind.config.js
Normal file
29
frontend/tailwind.config.js
Normal file
@@ -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: [],
|
||||||
|
}
|
||||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
1939
mailadmin.txt
Normal file
1939
mailadmin.txt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user