This commit is contained in:
2026-04-26 16:05:04 -05:00
parent 00932aeaff
commit fdcec00bc9
6 changed files with 487 additions and 148 deletions

View File

@@ -1,63 +1,147 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
hostname TEXT NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id SERIAL PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
current_node TEXT NOT NULL,
node_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ,
last_synced_at TIMESTAMPTZ,
notes TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
first_seen_at TIMESTAMPTZ DEFAULT now(),
last_seen_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS mailboxes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id SERIAL PRIMARY KEY,
email_address TEXT UNIQUE NOT NULL,
local_part TEXT NOT NULL,
domain TEXT NOT NULL REFERENCES domains(domain) ON DELETE CASCADE,
domain TEXT NOT NULL,
node_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
used_bytes BIGINT NOT NULL DEFAULT 0,
usage_scanned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
used_bytes BIGINT DEFAULT 0,
last_usage_scan_at TIMESTAMPTZ,
first_seen_at TIMESTAMPTZ DEFAULT now(),
last_seen_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'super_admin',
allowed_domains TEXT[] NOT NULL DEFAULT '{}',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id SERIAL PRIMARY KEY,
actor_email TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
details JSONB NOT NULL DEFAULT '{}',
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
target TEXT,
details JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_domains_node ON domains(current_node);
CREATE INDEX IF NOT EXISTS idx_mailboxes_domain ON mailboxes(domain);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
-- ============================================================
-- Upgrade existing MVP database
-- ============================================================
ALTER TABLE nodes
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
ALTER TABLE nodes
ADD COLUMN IF NOT EXISTS is_current BOOLEAN DEFAULT false;
ALTER TABLE domains
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
ALTER TABLE domains
ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ;
ALTER TABLE domains
ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT '';
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS local_part TEXT;
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS quota_bytes BIGINT;
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS quota_percent NUMERIC(8,3);
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS message_count BIGINT;
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS message_limit BIGINT;
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS usage_scanned_at TIMESTAMPTZ;
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
ALTER TABLE mailboxes
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS allowed_domains TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
ALTER TABLE audit_log
ADD COLUMN IF NOT EXISTS target_type TEXT;
ALTER TABLE audit_log
ADD COLUMN IF NOT EXISTS target_id TEXT;
ALTER TABLE audit_log
ADD COLUMN IF NOT EXISTS ip_address TEXT;
-- details existed already, but make it safer for newer code
ALTER TABLE audit_log
ALTER COLUMN details SET DEFAULT '{}';
-- Fill local_part for existing rows
UPDATE mailboxes
SET local_part = split_part(email_address, '@', 1)
WHERE local_part IS NULL
AND email_address LIKE '%@%';
-- Keep old and new usage timestamp columns in sync initially
UPDATE mailboxes
SET usage_scanned_at = last_usage_scan_at
WHERE usage_scanned_at IS NULL
AND last_usage_scan_at IS NOT NULL;
-- Backfill new audit target columns from old target column
UPDATE audit_log
SET target_id = target
WHERE target_id IS NULL
AND target IS NOT NULL;
UPDATE audit_log
SET target_type = 'unknown'
WHERE target_type IS NULL;
-- Useful indexes
CREATE INDEX IF NOT EXISTS idx_domains_node_name
ON domains(node_name);
CREATE INDEX IF NOT EXISTS idx_mailboxes_domain
ON mailboxes(domain);
CREATE INDEX IF NOT EXISTS idx_mailboxes_node_name
ON mailboxes(node_name);
CREATE INDEX IF NOT EXISTS idx_audit_created
ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_admin_users_allowed_domains
ON admin_users USING GIN(allowed_domains);

View File

@@ -1,4 +1,7 @@
import pg from 'pg';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const { Pool } = pg;
@@ -10,73 +13,188 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getBackendRootDir(): string {
const currentFile = fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
/**
* During development:
* backend/src/db.ts
*
* After TypeScript build:
* backend/dist/db.js
*
* We want:
* backend/
*/
if (currentDir.endsWith('/src') || currentDir.endsWith('\\src')) {
return path.resolve(currentDir, '..');
}
if (currentDir.endsWith('/dist') || currentDir.endsWith('\\dist')) {
return path.resolve(currentDir, '..');
}
return path.resolve(currentDir, '..');
}
async function tableExists(tableName: string): Promise<boolean> {
const result = await pool.query(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
) AS exists
`,
[tableName],
);
return Boolean(result.rows[0]?.exists);
}
async function runMigrations() {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
filename TEXT UNIQUE NOT NULL,
executed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
const backendRoot = getBackendRootDir();
const migrationsDir = path.join(backendRoot, 'migrations');
let files: string[] = [];
try {
files = await fs.readdir(migrationsDir);
} catch (err: any) {
throw new Error(
`Migrations directory not found: ${migrationsDir}. ` +
`Make sure backend/migrations is copied into the Docker image. Original error: ${err.message}`,
);
}
const sqlFiles = files
.filter((file) => file.endsWith('.sql'))
.sort((a, b) => a.localeCompare(b));
for (const file of sqlFiles) {
const alreadyExecuted = await pool.query(
`SELECT 1 FROM schema_migrations WHERE filename = $1`,
[file],
);
/**
* Special case:
* The first MVP version created tables inline in db.ts and had no migration system.
* Therefore 001_init.sql must still run once to apply ALTER TABLE upgrades.
*
* For later migrations this mechanism prevents duplicate execution.
*/
const shouldRun =
alreadyExecuted.rowCount === 0 ||
file === '001_init.sql';
if (!shouldRun) {
continue;
}
const fullPath = path.join(migrationsDir, file);
const sql = await fs.readFile(fullPath, 'utf8');
console.log(`Running database migration: ${file}`);
await pool.query('BEGIN');
try {
await pool.query(sql);
await pool.query(
`
INSERT INTO schema_migrations(filename)
VALUES ($1)
ON CONFLICT (filename) DO UPDATE
SET executed_at = now()
`,
[file],
);
await pool.query('COMMIT');
console.log(`✓ Migration completed: ${file}`);
} catch (err) {
await pool.query('ROLLBACK');
console.error(`✗ Migration failed: ${file}`);
throw err;
}
}
}
async function seedInitialAdmin() {
const adminEmail = process.env.MAILADMIN_ADMIN_EMAIL;
const adminPassword = process.env.MAILADMIN_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
console.warn(
'MAILADMIN_ADMIN_EMAIL or MAILADMIN_ADMIN_PASSWORD is not set. Skipping initial admin creation.',
);
return;
}
const existing = await pool.query(
`SELECT id FROM admin_users WHERE email = $1`,
[adminEmail.toLowerCase()],
);
if (existing.rowCount > 0) {
return;
}
/**
* bcryptjs is already used by the auth route in the MVP.
* Dynamic import avoids requiring it before DB startup.
*/
const bcrypt = await import('bcryptjs');
const passwordHash = await bcrypt.hash(adminPassword, 12);
await pool.query(
`
INSERT INTO admin_users (
email,
password_hash,
role,
allowed_domains,
active
)
VALUES ($1, $2, 'super_admin', '{}', true)
`,
[adminEmail.toLowerCase(), passwordHash],
);
console.log(`✓ Initial super admin created: ${adminEmail}`);
}
export async function initDb() {
const maxAttempts = 30;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await pool.query('SELECT 1');
console.log(`✓ PostgreSQL connected`);
console.log('✓ PostgreSQL connected');
await pool.query(`
CREATE TABLE IF NOT EXISTS nodes (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
hostname TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
`);
await runMigrations();
await pool.query(`
CREATE TABLE IF NOT EXISTS domains (
id SERIAL PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
node_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
first_seen_at TIMESTAMPTZ DEFAULT now(),
last_seen_at TIMESTAMPTZ DEFAULT now()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS mailboxes (
id SERIAL PRIMARY KEY,
email_address TEXT UNIQUE NOT NULL,
domain TEXT NOT NULL,
node_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
used_bytes BIGINT DEFAULT 0,
last_usage_scan_at TIMESTAMPTZ,
first_seen_at TIMESTAMPTZ DEFAULT now(),
last_seen_at TIMESTAMPTZ DEFAULT now()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'super_admin',
created_at TIMESTAMPTZ DEFAULT now()
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
actor_email TEXT,
action TEXT NOT NULL,
target TEXT,
details JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
`);
const hasAdminUsers = await tableExists('admin_users');
if (hasAdminUsers) {
await seedInitialAdmin();
}
return;
} catch (err: any) {
console.warn(
`PostgreSQL not ready yet (${attempt}/${maxAttempts}): ${err.message}`
`PostgreSQL not ready yet (${attempt}/${maxAttempts}): ${err.message}`,
);
if (attempt === maxAttempts) {

View File

@@ -20,12 +20,70 @@ function ensureDomain(req: any, domain: string): void {
if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 });
}
async function refreshQuotaForMailbox(emailAddress: string): Promise<void> {
const quota = await dms.getMailboxQuota(emailAddress);
await pool.query(
`UPDATE mailboxes
SET used_bytes=$2,
quota_bytes=$3,
quota_percent=$4,
message_count=$5,
message_limit=$6,
usage_scanned_at=now(),
updated_at=now()
WHERE email_address=$1`,
[
emailAddress,
quota.usedBytes,
quota.quotaBytes,
quota.quotaPercent,
quota.messageCount,
quota.messageLimit,
],
);
}
async function refreshQuotaForDomain(req: any, domain: string): Promise<number> {
ensureDomain(req, domain);
const rows = (await pool.query(
`SELECT email_address, domain
FROM mailboxes
WHERE node_name=$1 AND domain=$2 AND status='active'
ORDER BY email_address`,
[config.nodeName, domain],
)).rows;
let count = 0;
for (const row of rows) {
if (!canAccessDomain(req.user!, row.domain)) continue;
try {
await refreshQuotaForMailbox(row.email_address);
count++;
} catch (err) {
console.warn(`Could not refresh quota for ${row.email_address}:`, err);
}
}
return count;
}
mailboxesRouter.get('/', async (req, res) => {
const domain = String(req.query.domain ?? '').toLowerCase();
if (domain) ensureDomain(req, domain);
const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
if (domain) {
ensureDomain(req, domain);
if (refreshQuota) {
const count = await refreshQuotaForDomain(req, domain);
await audit(req.user!.email, 'mailbox.quota_refresh', 'domain', domain, { count }, req.ip);
}
}
const params: unknown[] = [config.nodeName];
let where = 'WHERE node_name=$1';
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
const result = await pool.query(
`SELECT * FROM mailboxes ${where} ORDER BY domain, local_part`,
params,
@@ -40,6 +98,7 @@ mailboxesRouter.post('/', async (req, res) => {
ensureDomain(req, domain);
await dms.addMailbox(email, body.password);
await sync.syncFromDms();
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip);
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
});
@@ -64,24 +123,6 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
res.json({ ok: true });
});
mailboxesRouter.post('/usage/rescan', async (req, res) => {
const domain = String(req.body?.domain ?? '').toLowerCase();
if (domain) ensureDomain(req, domain);
const params: unknown[] = [config.nodeName];
let where = `WHERE node_name=$1 AND status='active'`;
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
const rows = (await pool.query(`SELECT email_address, domain FROM mailboxes ${where}`, params)).rows;
let count = 0;
for (const row of rows) {
if (!canAccessDomain(req.user!, row.domain)) continue;
const bytes = await dms.getMailboxUsageBytes(row.email_address);
await pool.query(`UPDATE mailboxes SET used_bytes=$2, usage_scanned_at=now(), updated_at=now() WHERE email_address=$1`, [row.email_address, bytes]);
count++;
}
await audit(req.user!.email, 'mailbox.usage_rescan', 'domain', domain || '*', { count }, req.ip);
res.json({ count });
});
mailboxesRouter.get('/:email/rules', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
@@ -110,4 +151,4 @@ mailboxesRouter.put('/:email/blocklist', async (req, res) => {
const saved = await dynamo.putBlocklist(email, body.blocked_patterns);
await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip);
res.json(saved);
});
});

View File

@@ -1,6 +1,4 @@
import { existsSync } from 'node:fs';
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
import { config } from '../config.js';
import { run } from '../utils/shell.js';
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
@@ -11,6 +9,14 @@ export interface DmsAccount {
domain: string;
}
export interface DmsQuota {
usedBytes: number;
quotaBytes: number | null;
quotaPercent: number | null;
messageCount: number | null;
messageLimit: number | null;
}
function parseAccounts(output: string): DmsAccount[] {
const accounts: DmsAccount[] = [];
for (const line of output.split('\n')) {
@@ -22,6 +28,60 @@ function parseAccounts(output: string): DmsAccount[] {
return [...new Map(accounts.map((a) => [a.email, a])).values()].sort((a, b) => a.email.localeCompare(b.email));
}
function parseQuotaNumber(value: string | undefined): number | null {
if (!value || value === '-' || value.trim() === '') return null;
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) ? parsed : null;
}
function parseDoveadmQuota(output: string): DmsQuota {
let usedStorageKb: number | null = null;
let storageLimitKb: number | null = null;
let storagePercent: number | null = null;
let messageCount: number | null = null;
let messageLimit: number | null = null;
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.toLowerCase().startsWith('quota name')) continue;
const parts = trimmed.split(/\s+/);
const typeIndex = parts.findIndex((p) => p === 'STORAGE' || p === 'MESSAGE');
if (typeIndex < 0) continue;
const type = parts[typeIndex];
const value = parseQuotaNumber(parts[typeIndex + 1]);
const limit = parseQuotaNumber(parts[typeIndex + 2]);
const percent = parseQuotaNumber(parts[typeIndex + 3]);
if (type === 'STORAGE') {
usedStorageKb = value;
storageLimitKb = limit;
storagePercent = percent;
}
if (type === 'MESSAGE') {
messageCount = value;
messageLimit = limit;
}
}
const usedBytes = Math.max(0, usedStorageKb ?? 0) * 1024;
const quotaBytes = storageLimitKb && storageLimitKb > 0 ? storageLimitKb * 1024 : null;
// Dovecot's % column is integer-rounded. For the UI we calculate a more useful
// value ourselves, so small accounts do not always show exactly 0%.
const calculatedPercent = quotaBytes && quotaBytes > 0 ? (usedBytes / quotaBytes) * 100 : null;
return {
usedBytes,
quotaBytes,
quotaPercent: calculatedPercent ?? storagePercent,
messageCount,
messageLimit,
};
}
export class DmsService {
async listAccounts(): Promise<DmsAccount[]> {
const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']);
@@ -58,26 +118,13 @@ export class DmsService {
}
}
async getMailboxUsageBytes(email: string): Promise<number> {
const domain = domainFromEmail(email);
const local = localPartFromEmail(email);
const candidates = [
join(config.mailDataPath, domain, local),
join(config.mailDataPath, domain, `${local}/`),
join(config.mailDataPath, domain, `${local}/Maildir`),
join(config.mailDataPath, email),
];
for (const p of candidates) {
try {
await stat(p);
const { stdout } = await run('du', ['-sb', p], 60000);
const value = parseInt(stdout.trim().split(/\s+/)[0] ?? '0', 10);
return Number.isFinite(value) ? value : 0;
} catch {
// try next path
}
}
return 0;
async getMailboxQuota(email: string): Promise<DmsQuota> {
const normalized = normalizeEmail(email);
const { stdout } = await run(
'docker',
['exec', config.dmsContainer, 'doveadm', 'quota', 'get', '-u', normalized],
60000,
);
return parseDoveadmQuota(stdout);
}
}
}

View File

@@ -18,6 +18,28 @@ function bytes(n) {
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() {
@@ -29,11 +51,15 @@ async function init() {
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();
if (state.selectedDomain) await loadMailboxes(true);
}
async function loadMailboxes() {
state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}`);
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() {
@@ -41,6 +67,22 @@ async function loadAudit() {
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 = `
@@ -70,8 +112,8 @@ function render() {
</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.</div></div>
<div class="actions"><button id="newMailboxBtn">New mailbox</button><button class="secondary" id="usageBtn">Scan usage</button></div>
<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>
@@ -80,7 +122,7 @@ function render() {
<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>${bytes(m.used_bytes)}<div class="muted">${m.usage_scanned_at ? new Date(m.usage_scanned_at).toLocaleString() : 'not scanned'}</div></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>
@@ -99,8 +141,7 @@ function 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.getElementById('usageBtn').onclick = guard(async () => { await api('/api/mailboxes/usage/rescan', { method:'POST', body: JSON.stringify({ domain: state.selectedDomain }) }); await loadMailboxes(); render(); });
document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(); render(); }));
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));
@@ -127,13 +168,17 @@ function modal(html) {
}
function renderCreateMailboxModal() {
const d = modal(`<h2>New mailbox</h2><form id="createForm" class="form-grid"><label>Email<input name="email" type="email" value="@${esc(state.selectedDomain || '')}" required></label><label>Password<input name="password" type="password" minlength="8" required></label><div><button>Create</button></div></form>`);
const domain = state.selectedDomain || '';
const d = modal(`<h2>New mailbox</h2><form id="createForm" class="form-grid"><label>Email<input name="email" type="email" value="@${esc(domain)}" placeholder="@${esc(domain)}" required></label><label>Password<input name="password" type="password" value="" minlength="8" autocomplete="new-password" required></label><div><button>Create</button></div></form>`);
const input = d.querySelector('input[name="email"]');
input.focus();
input.setSelectionRange(0, 0);
d.querySelector('#createForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api('/api/mailboxes', { method:'POST', body: JSON.stringify({ email:f.get('email'), password:f.get('password') }) }); d.remove(); await loadDomains(false); render(); });
}
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(); render(); });
d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(true); render(); });
}
function renderPasswordModal(email) {
@@ -159,4 +204,4 @@ function renderAuditModal() {
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();
init();

View File

@@ -1,4 +1,4 @@
:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; }
: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; }
@@ -32,4 +32,8 @@ textarea { min-height: 110px; }
.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; }
@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } }
.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; } }