sso fuer inbox config
This commit is contained in:
@@ -8,3 +8,7 @@ DMS_CONTAINER=mailserver
|
||||
AWS_REGION=us-east-2
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
# Shared secret with the Roundcube "EMail Configuration" plugin.
|
||||
# Comma separated to allow rotation. Empty = webmail SSO disabled.
|
||||
MAILADMIN_WEBMAIL_SSO_SECRET=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
frontend.old
|
||||
node_modules
|
||||
@@ -23,3 +23,7 @@ MAILDATA_PATH=/mail-data
|
||||
AWS_REGION=us-east-2
|
||||
DYNAMODB_RULES_TABLE=email-rules
|
||||
DYNAMODB_BLOCKED_TABLE=email-blocked-senders
|
||||
|
||||
# Shared secret with the Roundcube "EMail Configuration" plugin.
|
||||
# Comma separated to allow rotation. Empty = webmail SSO disabled.
|
||||
WEBMAIL_SSO_SECRET=
|
||||
|
||||
@@ -11,6 +11,19 @@ export const config = {
|
||||
adminEmail: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
||||
adminPassword: process.env.ADMIN_PASSWORD ?? 'ChangeMe123!',
|
||||
|
||||
// Shared secret(s) with the Roundcube "EMail Configuration" plugin. Comma
|
||||
// separated so a secret can be rotated without downtime: links signed with
|
||||
// either the old or the new secret are accepted while both are listed.
|
||||
// Empty (the default) disables the webmail SSO endpoint entirely — there is
|
||||
// deliberately no fallback secret.
|
||||
webmailSsoSecrets: (process.env.WEBMAIL_SSO_SECRET ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
// How far in the future a link's `expires` may be. Guards against links that
|
||||
// never expire if the plugin is ever misconfigured.
|
||||
webmailSsoMaxLifetimeSec: parseInt(process.env.WEBMAIL_SSO_MAX_LIFETIME ?? '86400', 10),
|
||||
|
||||
nodeName: process.env.NODE_NAME ?? 'node1',
|
||||
nodeHostname: process.env.NODE_HOSTNAME ?? 'node1.email-srvr.com',
|
||||
|
||||
|
||||
@@ -15,8 +15,19 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export function signUser(user: AuthUser): string {
|
||||
return jwt.sign(user, config.jwtSecret, { expiresIn: '12h' });
|
||||
// Role given to a webmail user that arrived through a signed link from the
|
||||
// Roundcube plugin. It is never stored in admin_users — it only ever exists
|
||||
// inside a JWT — and may do nothing but read/write the forwarding, out of
|
||||
// office and blocklist rules of its own address (see requireAdmin /
|
||||
// requireSelfMailbox below).
|
||||
export const MAILBOX_USER_ROLE = 'mailbox_user';
|
||||
|
||||
export function signUser(user: AuthUser, expiresIn: string = '12h'): string {
|
||||
return jwt.sign(user, config.jwtSecret, { expiresIn } as jwt.SignOptions);
|
||||
}
|
||||
|
||||
export function isMailboxUser(user: AuthUser | undefined): boolean {
|
||||
return user?.role === MAILBOX_USER_ROLE;
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
@@ -45,6 +56,25 @@ export function requireSuperAdmin(req: Request, res: Response, next: NextFunctio
|
||||
next();
|
||||
}
|
||||
|
||||
// Blocks everything that is not plain mailbox self-service. Applied to every
|
||||
// admin router and to the admin-only mailbox routes, so a webmail SSO token
|
||||
// cannot list, create, delete or re-password any mailbox — not even in its
|
||||
// own domain.
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
if (isMailboxUser(req.user)) {
|
||||
res.status(403).json({ error: 'Forbidden: admin role required' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function canAccessDomain(user: AuthUser, domain: string): boolean {
|
||||
// A mailbox user never gets domain-wide access; it is confined to its own
|
||||
// address by the mailboxes router guard.
|
||||
if (isMailboxUser(user)) return false;
|
||||
return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase());
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
import { requireAuth, signUser } from '../middleware/auth.js';
|
||||
import { MAILBOX_USER_ROLE, requireAdmin, requireAuth, signUser } from '../middleware/auth.js';
|
||||
import { audit } from '../services/audit.js';
|
||||
import { domainFromEmail, normalizeEmail } from '../utils/email.js';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
@@ -31,6 +33,90 @@ authRouter.post('/login', async (req, res) => {
|
||||
res.json({ email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] });
|
||||
});
|
||||
|
||||
// --- Webmail single sign-on -------------------------------------------------
|
||||
//
|
||||
// The Roundcube plugin builds a link
|
||||
// https://mailadmin.<domain>/?email=<addr>&expires=<unix>&signature=<hex>
|
||||
// with signature = hash_hmac('sha256', "<addr>|<expires>", <shared secret>).
|
||||
// The frontend posts those three values here on boot and, if they check out,
|
||||
// receives the normal session cookie — but with the heavily restricted
|
||||
// mailbox_user role, scoped to that one address.
|
||||
|
||||
const ssoSchema = z.object({
|
||||
email: z.string().email(),
|
||||
expires: z.coerce.number().int(),
|
||||
signature: z.string().regex(/^[0-9a-fA-F]{64}$/),
|
||||
});
|
||||
|
||||
// Constant-time compare over the raw bytes. Comparing hex strings with === is
|
||||
// what leaks the signature one character at a time, so don't.
|
||||
function signatureMatches(expectedHex: string, providedHex: string): boolean {
|
||||
const expected = Buffer.from(expectedHex, 'hex');
|
||||
const provided = Buffer.from(providedHex, 'hex');
|
||||
if (expected.length !== provided.length) return false;
|
||||
return crypto.timingSafeEqual(expected, provided);
|
||||
}
|
||||
|
||||
authRouter.post('/webmail-sso', async (req, res) => {
|
||||
// No secret configured => the feature is off, not "open".
|
||||
if (config.webmailSsoSecrets.length === 0) {
|
||||
res.status(404).json({ error: 'Webmail SSO is not enabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = ssoSchema.parse(req.body);
|
||||
const email = normalizeEmail(body.email);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (body.expires <= nowSec) {
|
||||
res.status(401).json({ error: 'This link has expired. Please open it again from webmail.' });
|
||||
return;
|
||||
}
|
||||
if (body.expires > nowSec + config.webmailSsoMaxLifetimeSec) {
|
||||
res.status(401).json({ error: 'Invalid link' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign the address exactly as the plugin received it from Roundcube. The
|
||||
// plugin does not lower-case, so verify against both spellings.
|
||||
const candidates = new Set([`${body.email}|${body.expires}`, `${email}|${body.expires}`]);
|
||||
const valid = config.webmailSsoSecrets.some((secret) =>
|
||||
[...candidates].some((data) =>
|
||||
signatureMatches(crypto.createHmac('sha256', secret).update(data).digest('hex'), body.signature),
|
||||
),
|
||||
);
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Invalid link' });
|
||||
return;
|
||||
}
|
||||
|
||||
// The signature only proves the link was minted by the plugin — it does not
|
||||
// prove the mailbox lives on this node. Check that separately.
|
||||
const mailbox = (await pool.query(
|
||||
`SELECT email_address, domain FROM mailboxes
|
||||
WHERE email_address=$1 AND node_name=$2 AND status='active'`,
|
||||
[email, config.nodeName],
|
||||
)).rows[0];
|
||||
if (!mailbox) {
|
||||
res.status(404).json({ error: 'No such mailbox on this server' });
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = mailbox.domain ?? domainFromEmail(email);
|
||||
const token = signUser(
|
||||
{ id: `mailbox:${email}`, email, role: MAILBOX_USER_ROLE, allowed_domains: [domain] },
|
||||
'1h',
|
||||
);
|
||||
res.cookie('mailadmin_token', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: config.cookieSecure,
|
||||
maxAge: 60 * 60 * 1000,
|
||||
});
|
||||
await audit(email, 'auth.webmail_sso', 'mailbox', email, { domain }, req.ip);
|
||||
res.json({ email, role: MAILBOX_USER_ROLE, allowed_domains: [domain] });
|
||||
});
|
||||
|
||||
authRouter.post('/logout', (_req, res) => {
|
||||
res.clearCookie('mailadmin_token');
|
||||
res.json({ ok: true });
|
||||
@@ -47,7 +133,7 @@ const changePwSchema = z.object({
|
||||
new_password: z.string().min(8),
|
||||
});
|
||||
|
||||
authRouter.post('/change-password', requireAuth, async (req, res) => {
|
||||
authRouter.post('/change-password', requireAuth, requireAdmin, async (req, res) => {
|
||||
const body = changePwSchema.parse(req.body);
|
||||
const result = await pool.query(
|
||||
`SELECT id, password_hash FROM admin_users WHERE email=$1 AND active=true`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db.js';
|
||||
import { config } from '../config.js';
|
||||
import { requireAuth, canAccessDomain } from '../middleware/auth.js';
|
||||
import { requireAuth, canAccessDomain, isMailboxUser } from '../middleware/auth.js';
|
||||
import { DmsService } from '../services/dms.js';
|
||||
import { SyncService } from '../services/sync.js';
|
||||
import { DynamoRulesService } from '../services/dynamodb.js';
|
||||
@@ -13,6 +13,37 @@ import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/em
|
||||
export const mailboxesRouter = Router();
|
||||
mailboxesRouter.use(requireAuth);
|
||||
|
||||
// Complete authorization for webmail SSO sessions, deny-by-default: the rules
|
||||
// and blocklist endpoints of their own address are the only thing they may
|
||||
// reach. Everything else on this router (list, create, delete, password,
|
||||
// quota) stays admin-only, and any route added later is admin-only unless it
|
||||
// is deliberately added to this pattern.
|
||||
//
|
||||
// Deliberately a plain synchronous middleware answering through `res`: a
|
||||
// `throw` inside the async route handlers below is not caught by Express and
|
||||
// would take the process down instead of returning 403.
|
||||
const SELF_SERVICE_ROUTE = /^\/([^/]+)\/(rules|blocklist)\/?$/;
|
||||
|
||||
mailboxesRouter.use((req, res, next) => {
|
||||
if (!isMailboxUser(req.user)) { next(); return; }
|
||||
|
||||
const match = SELF_SERVICE_ROUTE.exec(req.path);
|
||||
let target: string | null = null;
|
||||
if (match) {
|
||||
try {
|
||||
target = normalizeEmail(decodeURIComponent(match[1]));
|
||||
} catch {
|
||||
target = null; // malformed percent-encoding
|
||||
}
|
||||
}
|
||||
|
||||
if (!target || target !== normalizeEmail(req.user!.email)) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
const dms = new DmsService();
|
||||
const sync = new SyncService(dms);
|
||||
const dynamo = new DynamoRulesService();
|
||||
@@ -21,6 +52,14 @@ function ensureDomain(req: any, domain: string): void {
|
||||
if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
}
|
||||
|
||||
// Guard for the four self-service routes below. Webmail sessions are already
|
||||
// pinned to their own address by the router middleware above; admins still
|
||||
// need the usual per-domain check.
|
||||
function ensureMailboxAccess(req: any, email: string): void {
|
||||
if (isMailboxUser(req.user)) return;
|
||||
ensureDomain(req, domainFromEmail(email));
|
||||
}
|
||||
|
||||
async function refreshQuotaForMailbox(emailAddress: string): Promise<void> {
|
||||
const quota = await dms.getMailboxQuota(emailAddress);
|
||||
await pool.query(
|
||||
@@ -140,13 +179,13 @@ mailboxesRouter.post('/:email/quota', async (req, res) => {
|
||||
|
||||
mailboxesRouter.get('/:email/rules', async (req, res) => {
|
||||
const email = normalizeEmail(req.params.email);
|
||||
ensureDomain(req, domainFromEmail(email));
|
||||
ensureMailboxAccess(req, email);
|
||||
res.json(await dynamo.getRules(email));
|
||||
});
|
||||
|
||||
mailboxesRouter.put('/:email/rules', async (req, res) => {
|
||||
const email = normalizeEmail(req.params.email);
|
||||
ensureDomain(req, domainFromEmail(email));
|
||||
ensureMailboxAccess(req, email);
|
||||
const body = z.object({
|
||||
ooo_active: z.boolean().optional(),
|
||||
ooo_message: z.string().optional(),
|
||||
@@ -166,13 +205,13 @@ mailboxesRouter.put('/:email/rules', async (req, res) => {
|
||||
|
||||
mailboxesRouter.get('/:email/blocklist', async (req, res) => {
|
||||
const email = normalizeEmail(req.params.email);
|
||||
ensureDomain(req, domainFromEmail(email));
|
||||
ensureMailboxAccess(req, email);
|
||||
res.json(await dynamo.getBlocklist(email));
|
||||
});
|
||||
|
||||
mailboxesRouter.put('/:email/blocklist', async (req, res) => {
|
||||
const email = normalizeEmail(req.params.email);
|
||||
ensureDomain(req, domainFromEmail(email));
|
||||
ensureMailboxAccess(req, email);
|
||||
const body = z.object({ blocked_patterns: z.array(z.string()) }).parse(req.body);
|
||||
const saved = await dynamo.putBlocklist(email, body.blocked_patterns);
|
||||
await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { adminsRouter } from './routes/admins.js';
|
||||
import { billingRouter } from './routes/billing.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { SyncService } from './services/sync.js';
|
||||
import { requireAdmin, requireAuth } from './middleware/auth.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
@@ -41,12 +42,16 @@ app.get('/api/health', (_req, res) => {
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/domains', domainsRouter);
|
||||
// Everything below is admin territory. requireAdmin keeps a webmail SSO
|
||||
// session (role mailbox_user) out of it; /api/mailboxes guards itself per
|
||||
// route because its rules/blocklist endpoints are exactly what that session
|
||||
// is allowed to use.
|
||||
app.use('/api/domains', requireAuth, requireAdmin, domainsRouter);
|
||||
app.use('/api/mailboxes', mailboxesRouter);
|
||||
app.use('/api/audit', auditRouter);
|
||||
app.use('/api/admins', adminsRouter);
|
||||
app.use('/api/billing', billingRouter);
|
||||
app.use('/api/health', healthRouter);
|
||||
app.use('/api/audit', requireAuth, requireAdmin, auditRouter);
|
||||
app.use('/api/admins', requireAuth, requireAdmin, adminsRouter);
|
||||
app.use('/api/billing', requireAuth, requireAdmin, billingRouter);
|
||||
app.use('/api/health', requireAuth, requireAdmin, healthRouter);
|
||||
|
||||
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const status = err.status ?? err.statusCode ?? 500;
|
||||
|
||||
@@ -50,6 +50,9 @@ services:
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
|
||||
DYNAMODB_RULES_TABLE: ${DYNAMODB_RULES_TABLE:-email-rules}
|
||||
DYNAMODB_BLOCKED_TABLE: ${DYNAMODB_BLOCKED_TABLE:-email-blocked-senders}
|
||||
# Shared secret with the Roundcube "EMail Configuration" plugin.
|
||||
# Comma separated to allow rotation. Empty = webmail SSO disabled.
|
||||
WEBMAIL_SSO_SECRET: ${MAILADMIN_WEBMAIL_SSO_SECRET:-}
|
||||
volumes:
|
||||
# Needed so backend can call docker exec mailserver.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
@@ -19,12 +19,33 @@ import DomainQuotaModal from './components/DomainQuotaModal';
|
||||
import BillingModal from './components/BillingModal';
|
||||
import HealthModal from './components/HealthModal';
|
||||
import HealthBanner from './components/HealthBanner';
|
||||
import WebmailSettingsPage from './components/WebmailSettingsPage';
|
||||
|
||||
import { authAPI, domainsAPI, mailboxesAPI, healthAPI } from './services/api';
|
||||
|
||||
const MAILBOX_USER_ROLE = 'mailbox_user';
|
||||
|
||||
// Reads ?email=&expires=&signature= — the link the Roundcube plugin builds.
|
||||
// Returns null when this is a normal admin visit.
|
||||
function readWebmailLink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const email = params.get('email');
|
||||
const expires = params.get('expires');
|
||||
const signature = params.get('signature');
|
||||
if (!email || !expires || !signature) return null;
|
||||
return { email, expires, signature };
|
||||
}
|
||||
|
||||
// Drop the credentials from the address bar once they have been exchanged for
|
||||
// a cookie, so the signature does not linger in history, bookmarks or referers.
|
||||
function stripWebmailLink() {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [bootChecked, setBootChecked] = useState(false);
|
||||
const [ssoError, setSsoError] = useState(null);
|
||||
|
||||
const [domains, setDomains] = useState([]);
|
||||
const [selectedDomain, setSelectedDomain] = useState(null);
|
||||
@@ -52,6 +73,7 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const isSuperAdmin = user?.role === 'super_admin';
|
||||
const isMailboxUser = user?.role === MAILBOX_USER_ROLE;
|
||||
const hideDomainList = !isSuperAdmin && domains.length <= 1;
|
||||
|
||||
const loadDomains = useCallback(async (resync = false) => {
|
||||
@@ -82,6 +104,23 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const link = readWebmailLink();
|
||||
if (link) {
|
||||
// Signed link from webmail: exchange it for a session and go straight
|
||||
// to that mailbox's settings — no login mask.
|
||||
try {
|
||||
const me = await authAPI.webmailSso(link);
|
||||
stripWebmailLink();
|
||||
setUser(me);
|
||||
} catch (err) {
|
||||
stripWebmailLink();
|
||||
setSsoError(err.message || 'This link is no longer valid.');
|
||||
setUser(null);
|
||||
} finally {
|
||||
setBootChecked(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await authAPI.me();
|
||||
setUser(me);
|
||||
@@ -94,7 +133,9 @@ function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
// Webmail users have no domain view — and the admin endpoints below would
|
||||
// (correctly) answer 403 for them.
|
||||
if (!user || isMailboxUser) return;
|
||||
(async () => {
|
||||
setBusyMessage('Loading domains...');
|
||||
try {
|
||||
@@ -203,6 +244,22 @@ function App() {
|
||||
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
|
||||
}
|
||||
|
||||
// Bad or expired signature: say so instead of dropping the webmail user on
|
||||
// an admin login mask they have no credentials for.
|
||||
if (!user && ssoError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="card max-w-md text-center">
|
||||
<h1 className="text-lg font-semibold text-gray-900">Link no longer valid</h1>
|
||||
<p className="text-sm text-gray-600 mt-2">{ssoError}</p>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Please go back to webmail and open <span className="font-medium">Email Configuration</span> again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
@@ -212,6 +269,15 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isMailboxUser) {
|
||||
return (
|
||||
<>
|
||||
<WebmailSettingsPage email={user.email} onToast={showToast} />
|
||||
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
|
||||
132
frontend/src/components/MailboxSettings.jsx
Normal file
132
frontend/src/components/MailboxSettings.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiCornerUpRight, FiCalendar, FiSlash } from 'react-icons/fi';
|
||||
import LoadingOverlay from './LoadingOverlay';
|
||||
import Forwarding from './Forwarding';
|
||||
import OutOfOffice from './OutOfOffice';
|
||||
import BlockedSenders from './BlockedSenders';
|
||||
import { mailboxesAPI } from '../services/api';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'fwd', label: 'Forwarding', icon: FiCornerUpRight },
|
||||
{ id: 'ooo', label: 'Out of Office', icon: FiCalendar },
|
||||
{ id: 'block', label: 'Blocklist', icon: FiSlash },
|
||||
];
|
||||
|
||||
const emptyRule = (email) => ({
|
||||
email_address: email,
|
||||
ooo_active: false,
|
||||
ooo_message: '',
|
||||
ooo_content_type: 'text',
|
||||
forwards: [],
|
||||
});
|
||||
|
||||
// Tabs + loading + saving for one mailbox's forwarding, auto-reply and
|
||||
// blocklist. Rendered inside a Modal for admins (MailboxSettingsModal) and
|
||||
// full page for webmail users arriving through a signed link.
|
||||
const MailboxSettings = ({ active, email, initialTab = 'fwd', onToast }) => {
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [rule, setRule] = useState(null);
|
||||
const [blocklist, setBlocklist] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { setActiveTab(initialTab); }, [initialTab, email]);
|
||||
|
||||
// Load both /rules and /blocklist in parallel.
|
||||
useEffect(() => {
|
||||
if (!active || !email) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [r, b] = await Promise.all([
|
||||
mailboxesAPI.getRules(email).catch(() => emptyRule(email)),
|
||||
mailboxesAPI.getBlocklist(email).catch(() => ({
|
||||
email_address: email, blocked_patterns: [],
|
||||
})),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRule(r ? { ...emptyRule(email), ...r } : emptyRule(email));
|
||||
setBlocklist(b || { email_address: email, blocked_patterns: [] });
|
||||
} catch (err) {
|
||||
if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [active, email, onToast]);
|
||||
|
||||
// Merge updates with existing rule and persist.
|
||||
const saveRule = async (updates) => {
|
||||
const base = rule || emptyRule(email);
|
||||
const merged = {
|
||||
ooo_active: base.ooo_active ?? false,
|
||||
ooo_message: base.ooo_message ?? '',
|
||||
ooo_content_type: base.ooo_content_type ?? 'text',
|
||||
forwards: base.forwards ?? [],
|
||||
...updates,
|
||||
};
|
||||
try {
|
||||
const saved = await mailboxesAPI.putRules(email, merged);
|
||||
setRule({ email_address: email, ...merged, ...saved });
|
||||
onToast?.('Rule saved', 'success');
|
||||
} catch (err) {
|
||||
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const saveBlocklist = async (patterns) => {
|
||||
try {
|
||||
const saved = await mailboxesAPI.putBlocklist(email, patterns);
|
||||
setBlocklist({ email_address: email, blocked_patterns: patterns, ...saved });
|
||||
onToast?.('Block list saved', 'success');
|
||||
} catch (err) {
|
||||
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[400px]">
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-1">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
|
||||
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading || !rule || !blocklist ? (
|
||||
<div className="py-16 flex items-center justify-center">
|
||||
<LoadingOverlay message="Loading settings..." />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'fwd' && <Forwarding rule={rule} onSave={saveRule} />}
|
||||
{activeTab === 'ooo' && <OutOfOffice rule={rule} onSave={saveRule} />}
|
||||
{activeTab === 'block' && <BlockedSenders blocklist={blocklist} onSave={saveBlocklist} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MailboxSettings;
|
||||
@@ -1,138 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiCornerUpRight, FiCalendar, FiSlash } from 'react-icons/fi';
|
||||
import React from 'react';
|
||||
import Modal from './Modal';
|
||||
import LoadingOverlay from './LoadingOverlay';
|
||||
import Forwarding from './Forwarding';
|
||||
import OutOfOffice from './OutOfOffice';
|
||||
import BlockedSenders from './BlockedSenders';
|
||||
import { mailboxesAPI } from '../services/api';
|
||||
import MailboxSettings from './MailboxSettings';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'fwd', label: 'Forwarding', icon: FiCornerUpRight },
|
||||
{ id: 'ooo', label: 'Out of Office', icon: FiCalendar },
|
||||
{ id: 'block', label: 'Blocklist', icon: FiSlash },
|
||||
];
|
||||
const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={email || 'Mailbox settings'}
|
||||
subtitle="Forwarding, auto-reply and blocklist"
|
||||
size="md"
|
||||
>
|
||||
<MailboxSettings
|
||||
active={open}
|
||||
email={email}
|
||||
initialTab={initialTab}
|
||||
onToast={onToast}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const emptyRule = (email) => ({
|
||||
email_address: email,
|
||||
ooo_active: false,
|
||||
ooo_message: '',
|
||||
ooo_content_type: 'text',
|
||||
forwards: [],
|
||||
});
|
||||
|
||||
const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => {
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [rule, setRule] = useState(null);
|
||||
const [blocklist, setBlocklist] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { setActiveTab(initialTab); }, [initialTab, email]);
|
||||
|
||||
// Load both /rules and /blocklist in parallel when the modal opens.
|
||||
useEffect(() => {
|
||||
if (!open || !email) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [r, b] = await Promise.all([
|
||||
mailboxesAPI.getRules(email).catch(() => emptyRule(email)),
|
||||
mailboxesAPI.getBlocklist(email).catch(() => ({
|
||||
email_address: email, blocked_patterns: [],
|
||||
})),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRule(r ? { ...emptyRule(email), ...r } : emptyRule(email));
|
||||
setBlocklist(b || { email_address: email, blocked_patterns: [] });
|
||||
} catch (err) {
|
||||
if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, email, onToast]);
|
||||
|
||||
// Merge updates with existing rule and persist.
|
||||
const saveRule = async (updates) => {
|
||||
const base = rule || emptyRule(email);
|
||||
const merged = {
|
||||
ooo_active: base.ooo_active ?? false,
|
||||
ooo_message: base.ooo_message ?? '',
|
||||
ooo_content_type: base.ooo_content_type ?? 'text',
|
||||
forwards: base.forwards ?? [],
|
||||
...updates,
|
||||
};
|
||||
try {
|
||||
const saved = await mailboxesAPI.putRules(email, merged);
|
||||
setRule({ email_address: email, ...merged, ...saved });
|
||||
onToast?.('Rule saved', 'success');
|
||||
} catch (err) {
|
||||
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const saveBlocklist = async (patterns) => {
|
||||
try {
|
||||
const saved = await mailboxesAPI.putBlocklist(email, patterns);
|
||||
setBlocklist({ email_address: email, blocked_patterns: patterns, ...saved });
|
||||
onToast?.('Block list saved', 'success');
|
||||
} catch (err) {
|
||||
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={email || 'Mailbox settings'}
|
||||
subtitle="Forwarding, auto-reply and blocklist"
|
||||
size="md"
|
||||
>
|
||||
<div className="relative min-h-[400px]">
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-1">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
|
||||
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading || !rule || !blocklist ? (
|
||||
<div className="py-16 flex items-center justify-center">
|
||||
<LoadingOverlay message="Loading settings..." />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'fwd' && <Forwarding rule={rule} onSave={saveRule} />}
|
||||
{activeTab === 'ooo' && <OutOfOffice rule={rule} onSave={saveRule} />}
|
||||
{activeTab === 'block' && <BlockedSenders blocklist={blocklist} onSave={saveBlocklist} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MailboxSettingsModal;
|
||||
export default MailboxSettingsModal;
|
||||
|
||||
31
frontend/src/components/WebmailSettingsPage.jsx
Normal file
31
frontend/src/components/WebmailSettingsPage.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { FiMail } from 'react-icons/fi';
|
||||
import MailboxSettings from './MailboxSettings';
|
||||
|
||||
// Standalone page for users who came in from the Roundcube "EMail
|
||||
// Configuration" plugin. Same settings as the admin modal, but nothing else:
|
||||
// no domain list, no mailbox table, no admin actions.
|
||||
const WebmailSettingsPage = ({ email, onToast }) => (
|
||||
<div className="min-h-screen">
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center gap-3">
|
||||
<FiMail className="w-5 h-5 text-primary-600 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-bold text-gray-900">Email Configuration</h1>
|
||||
<p className="text-xs text-gray-500 truncate">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 py-6">
|
||||
<section className="card">
|
||||
<MailboxSettings active email={email} initialTab="fwd" onToast={onToast} />
|
||||
</section>
|
||||
<p className="text-xs text-gray-400 text-center mt-6">
|
||||
Changes are saved immediately. You can close this tab when you are done.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default WebmailSettingsPage;
|
||||
@@ -24,6 +24,10 @@ export const authAPI = {
|
||||
login: async (email, password) =>
|
||||
(await api.post('/api/auth/login', { email, password })).data,
|
||||
logout: async () => (await api.post('/api/auth/logout')).data,
|
||||
// Exchanges a signed link from the Roundcube plugin for a session cookie
|
||||
// that is limited to that one mailbox.
|
||||
webmailSso: async ({ email, expires, signature }) =>
|
||||
(await api.post('/api/auth/webmail-sso', { email, expires, signature })).data,
|
||||
changePassword: async (current_password, new_password) =>
|
||||
(await api.post('/api/auth/change-password', { current_password, new_password })).data,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user