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.
auditRouter.get('/', async (_req, res) => {
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;
}
const result = await pool.query(
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 1000`,
);
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);
});

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);

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

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
FiUsers, FiUser,
FiUsers, FiUser, FiHardDrive,
} from 'react-icons/fi';
import Login from './components/Login';
@@ -15,6 +15,7 @@ import ConfirmDialog from './components/ConfirmDialog';
import AuditLogModal from './components/AuditLogModal';
import AdminUsersModal from './components/AdminUsersModal';
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
import DomainQuotaModal from './components/DomainQuotaModal';
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
@@ -36,16 +37,15 @@ function App() {
const [showAudit, setShowAudit] = useState(false);
const [showAdmins, setShowAdmins] = useState(false);
const [showChangePw, setShowChangePw] = useState(false);
const [showDomainQuota, setShowDomainQuota] = useState(false);
const showToast = useCallback((message, type = 'success') => {
setToast({ message, type });
}, []);
const isSuperAdmin = user?.role === 'super_admin';
// Hide left column entirely if a domain admin only has a single domain.
const hideDomainList = !isSuperAdmin && domains.length <= 1;
// ---- data loading helpers ----
const loadDomains = useCallback(async (resync = false) => {
const list = await domainsAPI.list(resync);
setDomains(list);
@@ -58,7 +58,6 @@ function App() {
setMailboxes(list);
}, []);
// ---- initial boot ----
useEffect(() => {
(async () => {
try {
@@ -77,7 +76,6 @@ function App() {
(async () => {
setBusyMessage('Loading domains...');
try {
// Only super admin should trigger an upstream DMS resync on login.
const list = await loadDomains(isSuperAdmin);
const first = list[0]?.domain || null;
setSelectedDomain(first);
@@ -158,7 +156,15 @@ function App() {
}
};
// ---- render ----
const handleQuotaApplied = async () => {
setBusyMessage('Refreshing quotas...');
try {
await loadMailboxes(selectedDomain, true);
} finally {
setBusyMessage('');
}
};
if (!bootChecked) {
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
}
@@ -174,7 +180,6 @@ function App() {
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
<div className="min-w-0">
@@ -188,12 +193,13 @@ function App() {
<FiUser className="w-4 h-4 mr-2" />
My Password
</button>
{/* Audit log: super_admin only. */}
{isSuperAdmin && (
<>
<button onClick={() => setShowAudit(true)} className="btn-secondary">
<FiList className="w-4 h-4 mr-2" />
Audit Log
</button>
{isSuperAdmin && (
<>
<button onClick={() => setShowAdmins(true)} className="btn-secondary">
<FiUsers className="w-4 h-4 mr-2" />
Admins
@@ -212,10 +218,8 @@ function App() {
</div>
</header>
{/* Main */}
<main className="max-w-7xl mx-auto px-6 py-6">
<div className={`grid grid-cols-1 ${hideDomainList ? '' : 'lg:grid-cols-[320px_1fr]'} gap-6 items-start`}>
{/* Domains - hidden when single-domain user */}
{!hideDomainList && (
<section className="card">
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
@@ -259,9 +263,8 @@ function App() {
</section>
)}
{/* Mailboxes */}
<section className="card relative">
<div className="flex items-start justify-between gap-4 mb-5">
<div className="flex items-start justify-between gap-4 mb-5 flex-wrap">
<div>
<h2 className="text-base font-semibold text-gray-900">
{selectedDomain || 'Mailboxes'}
@@ -270,6 +273,19 @@ function App() {
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
</p>
</div>
<div className="flex flex-row flex-nowrap gap-2">
{/* Set domain-wide quota: super_admin only. */}
{isSuperAdmin && (
<button
onClick={() => setShowDomainQuota(true)}
disabled={!selectedDomain || mailboxes.length === 0}
className="btn-secondary"
title="Set quota for all mailboxes in this domain"
>
<FiHardDrive className="w-4 h-4 mr-2" />
Set quota
</button>
)}
<button
onClick={() => setShowNew(true)}
disabled={!selectedDomain}
@@ -279,6 +295,7 @@ function App() {
New mailbox
</button>
</div>
</div>
{mailboxes.length === 0 ? (
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
@@ -354,7 +371,6 @@ function App() {
</div>
</main>
{/* Modals */}
<MailboxSettingsModal
open={!!settingsTarget}
email={settingsTarget?.email}
@@ -388,11 +404,13 @@ function App() {
onConfirm={handleDelete}
onClose={() => setDeleteTarget(null)}
/>
{isSuperAdmin && (
<AuditLogModal
open={showAudit}
onClose={() => setShowAudit(false)}
onToast={showToast}
/>
)}
{isSuperAdmin && (
<AdminUsersModal
open={showAdmins}
@@ -401,6 +419,16 @@ function App() {
onToast={showToast}
/>
)}
{isSuperAdmin && (
<DomainQuotaModal
open={showDomainQuota}
domain={selectedDomain}
mailboxes={mailboxes}
onClose={() => setShowDomainQuota(false)}
onApplied={handleQuotaApplied}
onToast={showToast}
/>
)}
<ChangeMyPasswordModal
open={showChangePw}
onClose={() => setShowChangePw(false)}

View File

@@ -5,13 +5,6 @@ import LoadingOverlay from './LoadingOverlay';
import ConfirmDialog from './ConfirmDialog';
import { adminsAPI, domainsAPI } from '../services/api';
/**
* Two-pane modal:
* - 'list' shows all admins
* - 'form' shows create/edit form
*
* editTarget is null for "create new" or an existing admin for "edit".
*/
const AdminUsersModal = ({ open, currentUser, onClose, onToast }) => {
const [view, setView] = useState('list');
const [admins, setAdmins] = useState([]);
@@ -219,8 +212,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
// Tracks whether the user has manually typed in the email field.
// Once they have, we stop auto-filling to avoid clobbering their input.
const [emailTouched, setEmailTouched] = useState(isEdit);
const emailRef = useRef(null);
@@ -230,8 +221,7 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
);
// Auto-fill email with "@<domain>" when creating a NEW domain_admin
// and exactly one domain is selected, as long as the user hasn't
// manually edited the email field yet.
// and exactly one domain is selected.
useEffect(() => {
if (isEdit) return;
if (role !== 'domain_admin') return;
@@ -239,7 +229,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
if (allowedDomains.size === 1) {
const [onlyDomain] = [...allowedDomains];
setEmail(`@${onlyDomain}`);
// Place caret before the @ so the user can type the local part directly.
requestAnimationFrame(() => {
const el = emailRef.current;
if (el && document.activeElement === el) {
@@ -251,8 +240,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
}
}, [allowedDomains, role, isEdit, emailTouched]);
// When the user focuses the email field for the first time after
// an auto-fill, place the caret at position 0 so they can just type.
const handleEmailFocus = () => {
const el = emailRef.current;
if (el && !emailTouched && email.startsWith('@')) {
@@ -270,7 +257,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
const passwordMismatch = password.length > 0 && confirmPw.length > 0 && password !== confirmPw;
const passwordTooShort = password.length > 0 && password.length < 8;
// For new admins password is required.
const passwordRequired = !isEdit;
const passwordProvided = password.length > 0;
@@ -299,7 +285,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
if (isEdit) {
const payload = { role, allowed_domains: allowedList, active };
if (passwordProvided) payload.password = password;
// Don't send forbidden fields when editing self.
if (isSelf) {
delete payload.role;
delete payload.active;
@@ -324,23 +309,42 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
};
return (
// Wrap fields in a "fake form" with autoComplete="off" so that
// browsers don't autofill the user's own login credentials into the
// new-admin form. We also use uncommon name= values and a hidden
// dummy field as a decoy — Chromium ignores autoComplete="off" on
// login-shaped inputs unless tricks like these are used.
<div className="space-y-5">
<button onClick={onCancel} className="btn-ghost -ml-2">
<FiArrowLeft className="w-4 h-4 mr-1.5" />
Back to list
</button>
{/* Decoy fields to absorb Chrome's autofill attempts. */}
<div style={{ display: 'none' }} aria-hidden="true">
<input type="text" name="username" autoComplete="username" tabIndex={-1} />
<input type="password" name="password" autoComplete="current-password" tabIndex={-1} />
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
<input
ref={emailRef}
type="email"
type="text"
inputMode="email"
name="admin_create_email"
value={email}
onChange={(e) => { setEmail(e.target.value); setEmailTouched(true); }}
onFocus={handleEmailFocus}
className="input-field"
disabled={isEdit}
placeholder="admin@example.com"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
data-form-type="other"
data-lpignore="true"
/>
{isEdit && (
<p className="mt-1 text-xs text-gray-500">Email cannot be changed after creation.</p>
@@ -354,17 +358,25 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
<div className="grid grid-cols-2 gap-2">
<input
type="password"
name="admin_create_pw_new"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
placeholder="At least 8 characters"
autoComplete="new-password"
data-form-type="other"
data-lpignore="true"
/>
<input
type="password"
name="admin_create_pw_confirm"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
placeholder="Confirm"
autoComplete="new-password"
data-form-type="other"
data-lpignore="true"
/>
</div>
{passwordTooShort && <p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>}

View File

@@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react';
import { FiHardDrive, FiAlertTriangle } from 'react-icons/fi';
import Modal from './Modal';
import { mailboxesAPI } from '../services/api';
const QUOTA_OPTIONS = [5, 10, 15, 20, 25, 30, 35, 40];
const DomainQuotaModal = ({ open, domain, mailboxes, onClose, onApplied, onToast }) => {
const [selectedGb, setSelectedGb] = useState(10);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState({ done: 0, total: 0, current: '' });
const [errors, setErrors] = useState([]);
useEffect(() => {
if (open) {
setSelectedGb(10);
setBusy(false);
setProgress({ done: 0, total: 0, current: '' });
setErrors([]);
}
}, [open, domain]);
const activeMailboxes = (mailboxes || []).filter((m) => m.status === 'active');
const apply = async () => {
if (activeMailboxes.length === 0) return;
setBusy(true);
setErrors([]);
setProgress({ done: 0, total: activeMailboxes.length, current: '' });
const failed = [];
// Sequential to avoid hammering the DMS container with parallel
// docker exec calls and to give the user honest progress.
for (let i = 0; i < activeMailboxes.length; i++) {
const m = activeMailboxes[i];
setProgress({ done: i, total: activeMailboxes.length, current: m.email_address });
try {
await mailboxesAPI.setQuota(m.email_address, selectedGb);
} catch (err) {
failed.push({ email: m.email_address, message: err.message });
}
}
setProgress({ done: activeMailboxes.length, total: activeMailboxes.length, current: '' });
if (failed.length === 0) {
onToast?.(`Quota set to ${selectedGb} GB for ${activeMailboxes.length} mailboxes.`, 'success');
await onApplied?.();
onClose();
} else {
setErrors(failed);
onToast?.(
`${activeMailboxes.length - failed.length}/${activeMailboxes.length} succeeded. See dialog for failures.`,
'warning',
);
// Refresh anyway so the user sees what worked.
await onApplied?.();
}
setBusy(false);
};
return (
<Modal
open={open}
onClose={busy ? () => {} : onClose}
title="Set quota for all mailboxes"
subtitle={domain}
size="md"
>
<div className="space-y-5">
<div className="flex items-start gap-3 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<FiAlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-900">
This applies the selected quota to <strong>all {activeMailboxes.length} active mailboxes</strong> in
this domain. Existing per-mailbox overrides will be replaced.
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Quota size</label>
<div className="grid grid-cols-4 gap-2">
{QUOTA_OPTIONS.map((gb) => {
const isSel = selectedGb === gb;
return (
<button
key={gb}
type="button"
onClick={() => setSelectedGb(gb)}
disabled={busy}
className={`px-4 py-3 rounded-lg border-2 font-semibold transition-all ${
isSel
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{gb} GB
</button>
);
})}
</div>
</div>
{busy && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-600">
<span className="truncate">
{progress.current ? `Processing: ${progress.current}` : 'Finishing...'}
</span>
<span className="font-mono whitespace-nowrap">
{progress.done} / {progress.total}
</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-primary-600 transition-all duration-200"
style={{ width: `${progress.total ? (progress.done / progress.total) * 100 : 0}%` }}
/>
</div>
</div>
)}
{errors.length > 0 && (
<div className="border border-red-200 rounded-lg bg-red-50 p-3">
<p className="text-sm font-semibold text-red-800 mb-1">
{errors.length} mailbox{errors.length > 1 ? 'es' : ''} failed:
</p>
<ul className="text-xs text-red-700 space-y-1 max-h-32 overflow-y-auto custom-scrollbar">
{errors.map((e) => (
<li key={e.email}>
<span className="font-mono">{e.email}</span>: {e.message}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>
{errors.length > 0 ? 'Close' : 'Cancel'}
</button>
<button onClick={apply} className="btn-primary" disabled={busy || activeMailboxes.length === 0}>
<FiHardDrive className="w-4 h-4 mr-2" />
{busy ? 'Applying...' : `Apply ${selectedGb} GB to ${activeMailboxes.length} mailboxes`}
</button>
</div>
</div>
</Modal>
);
};
export default DomainQuotaModal;

View File

@@ -1,13 +1,11 @@
import axios from 'axios';
// Same-origin: backend serves the built frontend, vite dev proxies /api.
const api = axios.create({
baseURL: '/',
withCredentials: true,
headers: { 'Content-Type': 'application/json' },
});
// Centralized error normalization so callers always see err.message + err.statusCode.
api.interceptors.response.use(
(r) => r,
(err) => {
@@ -52,6 +50,12 @@ export const mailboxesAPI = {
`/api/mailboxes/${encodeURIComponent(email)}/password`,
{ password }
)).data,
// quota_gb: integer, e.g. 5, 10, 15...
setQuota: async (email, quota_gb) =>
(await api.post(
`/api/mailboxes/${encodeURIComponent(email)}/quota`,
{ quota_gb }
)).data,
getRules: async (email) =>
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
putRules: async (email, payload) =>