302 lines
14 KiB
JavaScript
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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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(); |