new features

This commit is contained in:
2026-04-28 17:18:09 -05:00
parent 830e52a4fa
commit ffe2204597
7 changed files with 291 additions and 94 deletions

View File

@@ -1,49 +1,14 @@
import { Router } from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
export const auditRouter = Router();
auditRouter.use(requireAuth);
auditRouter.use(requireSuperAdmin);
auditRouter.get('/', async (req, res) => {
const user = req.user!;
if (user.role === 'super_admin') {
// Super admin sees everything.
const result = await pool.query(
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
);
res.json(result.rows);
return;
}
// Domain admin: filter to entries that touch one of their domains.
// We fetch a slightly larger window because filtering happens after the LIMIT
// would skip relevant rows. 1000 should be plenty for a single domain.
const allowed = (user.allowed_domains ?? []).map((d) => d.toLowerCase());
if (allowed.length === 0) {
res.json([]);
return;
}
auditRouter.get('/', async (_req, res) => {
const result = await pool.query(
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 1000`,
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
);
const visible = result.rows
.filter((row) => {
const t = String(row.target_type ?? '').toLowerCase();
const id = String(row.target_id ?? '').toLowerCase();
if (t === 'domain') return allowed.includes(id);
if (t === 'mailbox') {
const at = id.lastIndexOf('@');
if (at < 0) return false;
return allowed.includes(id.slice(at + 1));
}
// 'node' and unknown types: globally scoped, hidden from domain admins.
return false;
})
.slice(0, 200);
res.json(visible);
res.json(result.rows);
});

View File

@@ -71,7 +71,6 @@ async function refreshQuotaForDomain(req: any, domain: string): Promise<number>
mailboxesRouter.get('/', async (req, res) => {
const domain = String(req.query.domain ?? '').toLowerCase();
const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
// Hide soft-deleted mailboxes by default. Set ?includeDeleted=true if you ever want them.
const includeDeleted = String(req.query.includeDeleted ?? '').toLowerCase() === 'true';
if (domain) {
@@ -126,6 +125,18 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
res.json({ ok: true });
});
// Set storage quota for a single mailbox.
// quota_gb is an integer between 1 and 1024 (GB).
mailboxesRouter.post('/:email/quota', async (req, res) => {
const body = z.object({ quota_gb: z.number().int().min(1).max(1024) }).parse(req.body);
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
await dms.setQuota(email, body.quota_gb);
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
await audit(req.user!.email, 'mailbox.quota_set', 'mailbox', email, { quota_gb: body.quota_gb }, req.ip);
res.json({ ok: true, email, quota_gb: body.quota_gb });
});
mailboxesRouter.get('/:email/rules', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
@@ -138,8 +149,6 @@ mailboxesRouter.put('/:email/rules', async (req, res) => {
const body = z.object({
ooo_active: z.boolean().optional(),
ooo_message: z.string().optional(),
// 'text' or 'html'. Stored as-is in DynamoDB so the email worker can
// pick the correct Content-Type when sending the auto-reply.
ooo_content_type: z.enum(['text', 'html']).optional(),
forwards: z.array(z.string().email()).optional(),
}).parse(req.body);
@@ -167,4 +176,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

@@ -179,6 +179,32 @@ export class DmsService {
console.log(`[dms] password updated for mailbox: ${normalized}`);
}
/**
* Set the storage quota for a mailbox via the docker-mailserver
* `setup quota set` subcommand. quotaGb must be a positive integer.
* Equivalent to: docker exec mailserver setup quota set user@example.com 35G
*/
async setQuota(email: string, quotaGb: number): Promise<void> {
const normalized = normalizeEmail(email);
const gb = Math.floor(Number(quotaGb));
if (!Number.isFinite(gb) || gb <= 0 || gb > 1024) {
throw new Error(`Invalid quota size: ${quotaGb}`);
}
console.log(`[dms] setting quota for ${normalized} to ${gb}G`);
await this.assertDockerAccess();
await run(
'docker',
['exec', config.dmsContainer, 'setup', 'quota', 'set', normalized, `${gb}G`],
120000,
);
console.log(`[dms] quota set for ${normalized}: ${gb}G`);
}
async syncSesDomain(domain: string): Promise<void> {
const normalizedDomain = domain.toLowerCase();
@@ -204,4 +230,4 @@ export class DmsService {
return parseDoveadmQuota(stdout);
}
}
}