changes
This commit is contained in:
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `
|
||||
<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();
|
||||
@@ -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; } }
|
||||
Reference in New Issue
Block a user