diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql index 8b518e1..9f57b74 100644 --- a/backend/migrations/001_init.sql +++ b/backend/migrations/001_init.sql @@ -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); \ No newline at end of file diff --git a/backend/src/db.ts b/backend/src/db.ts index d28e464..383c878 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -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 { + 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) { diff --git a/backend/src/routes/mailboxes.ts b/backend/src/routes/mailboxes.ts index 6c29183..9161bd1 100644 --- a/backend/src/routes/mailboxes.ts +++ b/backend/src/routes/mailboxes.ts @@ -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 { + 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 { + 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); -}); +}); \ No newline at end of file diff --git a/backend/src/services/dms.ts b/backend/src/services/dms.ts index 61413df..56b3d90 100644 --- a/backend/src/services/dms.ts +++ b/backend/src/services/dms.ts @@ -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 { const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']); @@ -58,26 +118,13 @@ export class DmsService { } } - async getMailboxUsageBytes(email: string): Promise { - 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 { + const normalized = normalizeEmail(email); + const { stdout } = await run( + 'docker', + ['exec', config.dmsContainer, 'doveadm', 'quota', 'get', '-u', normalized], + 60000, + ); + return parseDoveadmQuota(stdout); } -} +} \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js index 0b7e6b2..00fc37c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 ` +
+
Disk Usage: ${esc(usage.label)}
+
+
+
+
+ ${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` : ''} +
+
`; +} + function render() { if (!state.user) return renderLogin(); app.innerHTML = ` @@ -70,8 +112,8 @@ function render() {
-

${esc(state.selectedDomain || 'Mailboxes')}

Create/delete mailboxes, reset passwords, edit rules.
-
+

${esc(state.selectedDomain || 'Mailboxes')}

Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
+
@@ -80,7 +122,7 @@ function render() { - +
EmailStatusUsageUpdatedActions
${esc(m.email_address)}
${esc(m.node_name)}
${esc(m.status)}${bytes(m.used_bytes)}
${m.usage_scanned_at ? new Date(m.usage_scanned_at).toLocaleString() : 'not scanned'}
${renderUsage(m)} ${new Date(m.updated_at).toLocaleString()}
@@ -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(`

New mailbox

`); + const domain = state.selectedDomain || ''; + const d = modal(`

New mailbox

`); + 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(`

Delete mailbox

Delete ${esc(email)} from DMS?

`); - 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(); \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css index a2a3381..81afd10 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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; } } \ No newline at end of file