diff --git a/.env.example b/.env.example index 9c61aec..6989eeb 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index b5c06a8..c4ecae2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ frontend.old +node_modules \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 5d40a36..2af9767 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/src/config.ts b/backend/src/config.ts index 913fa52..12912f4 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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', diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 4ab11e2..9f92306 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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()); } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 05d205a..ebbcf38 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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./?email=&expires=&signature= +// with signature = hash_hmac('sha256', "|", ). +// 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`, diff --git a/backend/src/routes/mailboxes.ts b/backend/src/routes/mailboxes.ts index 811a517..a2a0b8c 100644 --- a/backend/src/routes/mailboxes.ts +++ b/backend/src/routes/mailboxes.ts @@ -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 { 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); diff --git a/backend/src/server.ts b/backend/src/server.ts index 98f186e..1d4eed5 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 84b2e38..1253476 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9c13849..ebc74da 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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
Loading...
; } + // 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 ( +
+
+

Link no longer valid

+

{ssoError}

+

+ Please go back to webmail and open Email Configuration again. +

+
+
+ ); + } + if (!user) { return ( <> @@ -212,6 +269,15 @@ function App() { ); } + if (isMailboxUser) { + return ( + <> + + {toast && setToast(null)} />} + + ); + } + return (
diff --git a/frontend/src/components/MailboxSettings.jsx b/frontend/src/components/MailboxSettings.jsx new file mode 100644 index 0000000..c43de73 --- /dev/null +++ b/frontend/src/components/MailboxSettings.jsx @@ -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 ( +
+ {/* Tabs */} +
+
+ {TABS.map((t) => { + const Icon = t.icon; + const isActive = activeTab === t.id; + return ( + + ); + })} +
+
+ + {/* Content */} + {loading || !rule || !blocklist ? ( +
+ +
+ ) : ( + <> + {activeTab === 'fwd' && } + {activeTab === 'ooo' && } + {activeTab === 'block' && } + + )} +
+ ); +}; + +export default MailboxSettings; diff --git a/frontend/src/components/MailboxSettingsModal.jsx b/frontend/src/components/MailboxSettingsModal.jsx index c93f9d2..622ea13 100644 --- a/frontend/src/components/MailboxSettingsModal.jsx +++ b/frontend/src/components/MailboxSettingsModal.jsx @@ -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 }) => ( + + + +); -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 ( - -
- {/* Tabs */} -
-
- {TABS.map((t) => { - const Icon = t.icon; - const isActive = activeTab === t.id; - return ( - - ); - })} -
-
- - {/* Content */} - {loading || !rule || !blocklist ? ( -
- -
- ) : ( - <> - {activeTab === 'fwd' && } - {activeTab === 'ooo' && } - {activeTab === 'block' && } - - )} -
-
- ); -}; - -export default MailboxSettingsModal; \ No newline at end of file +export default MailboxSettingsModal; diff --git a/frontend/src/components/WebmailSettingsPage.jsx b/frontend/src/components/WebmailSettingsPage.jsx new file mode 100644 index 0000000..6390f5e --- /dev/null +++ b/frontend/src/components/WebmailSettingsPage.jsx @@ -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 }) => ( +
+
+
+ +
+

Email Configuration

+

{email}

+
+
+
+ +
+
+ +
+

+ Changes are saved immediately. You can close this tab when you are done. +

+
+
+); + +export default WebmailSettingsPage; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 4832c43..5b41ec6 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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, };