Files
mailadmin/frontend/app.js
2026-04-26 16:28:38 -05:00

302 lines
14 KiB
JavaScript

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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();