diff --git a/README.md b/README.md index c057cba..5201d46 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,18 @@ nginx (frontend container — internal only) --- +## Upgrading + +### Mail Server Connection Policy + +**Breaking change for accounts with "Skip TLS verification" enabled.** + +This release introduces an admin-controlled connection policy (Settings → Security → Mail Server Connection Policy). TLS verification is now enforced by default at the server level. + +If any accounts were configured with **Skip TLS verification** (e.g. for a self-signed certificate on a local IMAP server), those accounts will stop syncing after upgrading. To restore connectivity, an admin must enable **Allow insecure TLS** in Settings → Security before or immediately after deploying. + +--- + ## Security notes - The first registered user becomes the admin automatically diff --git a/backend/migrations/0014_account_allow_private_host.sql b/backend/migrations/0014_account_allow_private_host.sql new file mode 100644 index 0000000..e02d0df --- /dev/null +++ b/backend/migrations/0014_account_allow_private_host.sql @@ -0,0 +1,4 @@ +-- Server-level policy flags for self-hosted mail server support +INSERT INTO system_settings (key, value) VALUES ('allow_private_hosts', 'false') ON CONFLICT (key) DO NOTHING; +INSERT INTO system_settings (key, value) VALUES ('allow_insecure_tls', 'false') ON CONFLICT (key) DO NOTHING; +INSERT INTO system_settings (key, value) VALUES ('allow_nonstandard_ports', 'false') ON CONFLICT (key) DO NOTHING; diff --git a/backend/src/routes/accounts.js b/backend/src/routes/accounts.js index e78bbe7..a8116d9 100644 --- a/backend/src/routes/accounts.js +++ b/backend/src/routes/accounts.js @@ -5,6 +5,7 @@ import { imapManager } from '../index.js'; import { encrypt } from '../services/encryption.js'; import { sanitizeSignature } from '../services/emailSanitizer.js'; import { validateHost } from '../services/hostValidation.js'; +import { getConnectionPolicy } from '../services/connectionPolicy.js'; const ALLOWED_IMAP_PORTS = new Set([143, 993]); const ALLOWED_SMTP_PORTS = new Set([465, 587]); @@ -98,12 +99,16 @@ router.post('/', async (req, res) => { return res.status(400).json({ error: 'Sender name cannot contain control characters' }); } + const policy = await getConnectionPolicy(); + if (imap_host) { - const err = (await validateHost(imap_host)) || validatePort(imap_port, ALLOWED_IMAP_PORTS); + const err = (await validateHost(imap_host, { allowPrivate: policy.allowPrivateHosts })) + || (!policy.allowNonstandardPorts && validatePort(imap_port, ALLOWED_IMAP_PORTS)); if (err) return res.status(400).json({ error: `IMAP: ${err}` }); } if (smtp_host) { - const err = (await validateHost(smtp_host)) || validatePort(smtp_port, ALLOWED_SMTP_PORTS); + const err = (await validateHost(smtp_host, { allowPrivate: policy.allowPrivateHosts })) + || (!policy.allowNonstandardPorts && validatePort(smtp_port, ALLOWED_SMTP_PORTS)); if (err) return res.status(400).json({ error: `SMTP: ${err}` }); } @@ -151,13 +156,17 @@ router.put('/:id', async (req, res) => { if ('sender_name' in updates && updates.sender_name && hasHeaderInjectionChars(updates.sender_name)) { return res.status(400).json({ error: 'Sender name cannot contain control characters' }); } + const policy = await getConnectionPolicy(); + if ('smtp_host' in updates && updates.smtp_host) { - const err = await validateHost(updates.smtp_host); + const err = await validateHost(updates.smtp_host, { allowPrivate: policy.allowPrivateHosts }); if (err) return res.status(400).json({ error: `SMTP: ${err}` }); } if ('smtp_port' in updates && updates.smtp_port !== undefined && updates.smtp_port !== null) { - const err = validatePort(updates.smtp_port, ALLOWED_SMTP_PORTS); - if (err) return res.status(400).json({ error: `SMTP: ${err}` }); + if (!policy.allowNonstandardPorts) { + const err = validatePort(updates.smtp_port, ALLOWED_SMTP_PORTS); + if (err) return res.status(400).json({ error: `SMTP: ${err}` }); + } } const allowed = ['name', 'sender_name', 'color', 'enabled', 'auth_user', 'auth_pass', 'sort_order', 'smtp_host', 'smtp_port', 'folder_mappings', 'imap_skip_tls_verify', 'signature']; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7a2c563..6d86426 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -5,6 +5,7 @@ import { query } from '../services/db.js'; import { requireAdmin } from '../middleware/auth.js'; import { decrypt, encrypt } from '../services/encryption.js'; import { validateHost, resolveForConnection } from '../services/hostValidation.js'; +import { getConnectionPolicy, invalidateConnectionPolicyCache } from '../services/connectionPolicy.js'; import { reloadAuthSettings } from '../services/authLimiter.js'; const router = Router(); @@ -95,7 +96,8 @@ router.get('/auth-events', async (req, res) => { }); router.patch('/settings', async (req, res) => { - const { registration_open, internal_auth_disabled, auth_max_attempts, auth_window_minutes } = req.body; + const { registration_open, internal_auth_disabled, auth_max_attempts, auth_window_minutes, + allow_private_hosts, allow_insecure_tls, allow_nonstandard_ports } = req.body; if (typeof registration_open === 'boolean') { await query( `INSERT INTO system_settings (key, value, updated_at) @@ -159,6 +161,21 @@ router.patch('/settings', async (req, res) => { if (auth_max_attempts != null || auth_window_minutes != null) { await reloadAuthSettings(); } + for (const [key, val] of [ + ['allow_private_hosts', allow_private_hosts], + ['allow_insecure_tls', allow_insecure_tls], + ['allow_nonstandard_ports', allow_nonstandard_ports], + ]) { + if (typeof val === 'boolean') { + await query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, + [key, val ? 'true' : 'false'] + ); + console.log(`[admin] ${req.session.username} set ${key}=${val}`); + } + } + invalidateConnectionPolicyCache(); res.json({ ok: true }); }); @@ -256,8 +273,9 @@ router.post('/invites', async (req, res) => { } else { smtpAuth = { user: account.auth_user, pass: decrypt(account.auth_pass) }; } - const acctResolved = await resolveForConnection(account.smtp_host); - const acctTls = { rejectUnauthorized: !account.imap_skip_tls_verify }; + const policy = await getConnectionPolicy(); + const acctResolved = await resolveForConnection(account.smtp_host, { allowPrivate: policy.allowPrivateHosts }); + const acctTls = { rejectUnauthorized: policy.allowInsecureTls ? !account.imap_skip_tls_verify : true }; if (acctResolved.servername) acctTls.servername = acctResolved.servername; transport = nodemailer.createTransport({ host: acctResolved.host, diff --git a/backend/src/routes/send.js b/backend/src/routes/send.js index 7761644..fd7f1b4 100644 --- a/backend/src/routes/send.js +++ b/backend/src/routes/send.js @@ -8,6 +8,7 @@ import { decrypt } from '../services/encryption.js'; import sanitizeHtml from 'sanitize-html'; import { redactEmail } from '../utils/redact.js'; import { resolveForConnection } from '../services/hostValidation.js'; +import { getConnectionPolicy } from '../services/connectionPolicy.js'; import { imapManager } from '../index.js'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -226,8 +227,13 @@ router.post('/send', async (req, res) => { smtpAuth = { user: account.auth_user, pass }; } - const smtpResolved = await resolveForConnection(account.smtp_host); - const smtpTls = { rejectUnauthorized: !account.imap_skip_tls_verify }; + const policy = await getConnectionPolicy(); + const smtpResolved = await resolveForConnection(account.smtp_host, { allowPrivate: policy.allowPrivateHosts }); + const smtpPlain = account.smtp_tls !== 'STARTTLS' && account.smtp_tls !== 'SSL'; + if (!policy.allowInsecureTls && smtpPlain) { + return res.status(403).json({ error: 'Plain-text SMTP is not allowed: admin must enable "Allow insecure TLS"' }); + } + const smtpTls = { rejectUnauthorized: !(policy.allowInsecureTls && account.imap_skip_tls_verify) }; if (smtpResolved.servername) smtpTls.servername = smtpResolved.servername; // For 'SSL': force direct TLS. For 'none': plain with no upgrade. // For 'STARTTLS' (or any other/legacy value): fall back to port-based detection diff --git a/backend/src/services/connectionPolicy.js b/backend/src/services/connectionPolicy.js new file mode 100644 index 0000000..bf7a4b5 --- /dev/null +++ b/backend/src/services/connectionPolicy.js @@ -0,0 +1,28 @@ +import { query } from './db.js'; + +const POLICY_TTL_MS = 30_000; +let _cache = null; +let _cacheAt = 0; + +export async function getConnectionPolicy() { + const now = Date.now(); + if (_cache && now - _cacheAt < POLICY_TTL_MS) return _cache; + const result = await query( + `SELECT key, value FROM system_settings + WHERE key IN ('allow_private_hosts', 'allow_insecure_tls', 'allow_nonstandard_ports')` + ); + const map = {}; + for (const row of result.rows) map[row.key] = row.value === 'true'; + _cache = { + allowPrivateHosts: !!map.allow_private_hosts, + allowInsecureTls: !!map.allow_insecure_tls, + allowNonstandardPorts: !!map.allow_nonstandard_ports, + }; + _cacheAt = now; + return _cache; +} + +// Call after PATCH /settings to pick up changes without waiting for TTL. +export function invalidateConnectionPolicyCache() { + _cache = null; +} diff --git a/backend/src/services/hostValidation.js b/backend/src/services/hostValidation.js index a8c5c3b..22c392f 100644 --- a/backend/src/services/hostValidation.js +++ b/backend/src/services/hostValidation.js @@ -40,9 +40,9 @@ function isPrivateIPv6(ip) { } // Synchronous check: literal IPs and reserved hostnames. -export function validateHostLiteral(host) { - if (process.env.ALLOW_PRIVATE_IMAP_HOSTS === 'true') return null; +export function validateHostLiteral(host, { allowPrivate = false } = {}) { if (!host || typeof host !== 'string') return null; + if (allowPrivate) return null; const h = host.trim().toLowerCase(); if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost') || h.endsWith('.internal')) { return 'Host cannot be a local address'; @@ -55,8 +55,8 @@ export function validateHostLiteral(host) { // Async check: resolve A/AAAA records and reject any that are private/reserved. // Prevents SSRF via controlled hostnames that resolve to internal addresses. -export async function validateHost(host) { - const literalErr = validateHostLiteral(host); +export async function validateHost(host, { allowPrivate = false } = {}) { + const literalErr = validateHostLiteral(host, { allowPrivate }); if (literalErr) return literalErr; const h = host.trim().toLowerCase(); @@ -73,9 +73,11 @@ export async function validateHost(host) { dnsPromises.resolve6(bare).catch(() => []), ]); - for (const addr of [...v4, ...v6]) { - if (isIPv4(addr) && isPrivateIPv4(addr)) return 'Host resolves to a private or reserved IP address'; - if (isIPv6(addr) && isPrivateIPv6(addr)) return 'Host resolves to a private or reserved IP address'; + if (!allowPrivate) { + for (const addr of [...v4, ...v6]) { + if (isIPv4(addr) && isPrivateIPv4(addr)) return 'Host resolves to a private or reserved IP address'; + if (isIPv6(addr) && isPrivateIPv6(addr)) return 'Host resolves to a private or reserved IP address'; + } } return null; @@ -90,8 +92,9 @@ export async function validateHost(host) { // already a literal IP, since SNI override is not needed in that case) // // Throws if the host is a reserved/private literal or if DNS resolves to a private range. -export async function resolveForConnection(hostname) { - const literalErr = validateHostLiteral(hostname); +// Pass { allowPrivate: true } to skip all private/local checks (for self-hosted servers). +export async function resolveForConnection(hostname, { allowPrivate = false } = {}) { + const literalErr = validateHostLiteral(hostname, { allowPrivate }); if (literalErr) throw new Error(literalErr); const h = hostname.trim(); @@ -105,16 +108,18 @@ export async function resolveForConnection(hostname) { dnsPromises.resolve6(bare).catch(() => []), ]); - for (const addr of [...v4, ...v6]) { - if (isIPv4(addr) && isPrivateIPv4(addr)) throw new Error('Host resolves to a private or reserved IP address'); - if (isIPv6(addr) && isPrivateIPv6(addr)) throw new Error('Host resolves to a private or reserved IP address'); + if (!allowPrivate) { + for (const addr of [...v4, ...v6]) { + if (isIPv4(addr) && isPrivateIPv4(addr)) throw new Error('Host resolves to a private or reserved IP address'); + if (isIPv6(addr) && isPrivateIPv6(addr)) throw new Error('Host resolves to a private or reserved IP address'); + } } const all = [...v4, ...v6]; // DNS failed — let the connection attempt fail naturally (NXDOMAIN etc.). if (!all.length) return { host: h, servername: null }; - // Pin to first validated public IP. Pass servername so TLS SNI and cert verification + // Pin to first validated IP. Pass servername so TLS SNI and cert verification // still use the hostname even though the socket connects directly to the IP. return { host: all[0], servername: h }; } diff --git a/backend/src/services/hostValidation.test.js b/backend/src/services/hostValidation.test.js index c4a7456..3577b96 100644 --- a/backend/src/services/hostValidation.test.js +++ b/backend/src/services/hostValidation.test.js @@ -194,3 +194,48 @@ describe('resolveForConnection', () => { expect(result.servername).toBe('imap.gmail.com'); }); }); + +// ── allowPrivate option ──────────────────────────────────────────────────── + +describe('allowPrivate option', () => { + it('validateHostLiteral passes private IPv4 when allowPrivate is true', () => { + expect(validateHostLiteral('127.0.0.1', { allowPrivate: true })).toBeNull(); + expect(validateHostLiteral('192.168.1.1', { allowPrivate: true })).toBeNull(); + expect(validateHostLiteral('10.0.0.1', { allowPrivate: true })).toBeNull(); + }); + + it('validateHostLiteral passes localhost when allowPrivate is true', () => { + expect(validateHostLiteral('localhost', { allowPrivate: true })).toBeNull(); + expect(validateHostLiteral('mail.local', { allowPrivate: true })).toBeNull(); + }); + + it('validateHostLiteral still blocks private hosts when allowPrivate is false (default)', () => { + expect(validateHostLiteral('127.0.0.1')).not.toBeNull(); + expect(validateHostLiteral('localhost')).not.toBeNull(); + }); + + it('validateHost passes private IPv4 when allowPrivate is true', async () => { + expect(await validateHost('192.168.1.1', { allowPrivate: true })).toBeNull(); + }); + + it('validateHost passes a hostname resolving to a private IP when allowPrivate is true', async () => { + dns.resolve4.mockResolvedValue(['192.168.1.100']); + expect(await validateHost('protonmail.local', { allowPrivate: true })).toBeNull(); + }); + + it('resolveForConnection does not throw for localhost when allowPrivate is true', async () => { + await expect(resolveForConnection('localhost', { allowPrivate: true })).resolves.toBeDefined(); + }); + + it('resolveForConnection does not throw for a private IPv4 when allowPrivate is true', async () => { + const result = await resolveForConnection('127.0.0.1', { allowPrivate: true }); + expect(result.host).toBe('127.0.0.1'); + expect(result.servername).toBeNull(); + }); + + it('resolveForConnection does not throw for a hostname resolving to private IP when allowPrivate is true', async () => { + dns.resolve4.mockResolvedValue(['192.168.1.100']); + const result = await resolveForConnection('bridge.local', { allowPrivate: true }); + expect(result.host).toBe('192.168.1.100'); + }); +}); diff --git a/backend/src/services/imapManager.js b/backend/src/services/imapManager.js index 32b15b2..899bd8f 100644 --- a/backend/src/services/imapManager.js +++ b/backend/src/services/imapManager.js @@ -9,12 +9,21 @@ import { sendPushToUser } from './pushNotifications.js'; import { redactEmail } from '../utils/redact.js'; import { adjustFolderCounts } from '../utils/mailUtils.js'; import { resolveForConnection } from './hostValidation.js'; +import { getConnectionPolicy } from './connectionPolicy.js'; import { applyInboxRules, applyBlockList } from './inboxRules.js'; // Shorthand for log lines — keeps domain visible while masking the local part. const logAccount = (account) => redactEmail(account?.email_address || ''); +// Resolves the IMAP host for an account, applying server-level connection policy. +// Returns { resolved, policy } so callers can pass policy to makeClientCfg. +const resolveAccountHost = async (account) => { + const policy = await getConnectionPolicy(); + const resolved = await resolveForConnection(account.imap_host, { allowPrivate: policy.allowPrivateHosts }); + return { resolved, policy }; +}; + // Body parts that cover ~99% of real-world email structures (used for full body caching) const BODY_PREFETCH_PARTS = ['1', '1.1', '1.2', '2', '2.1', '2.2', '1.1.1', '1.2.1']; @@ -394,8 +403,13 @@ async function ensureFreshToken(account) { // resolved: { host, servername } from resolveForConnection() — pins the IP so the // actual TCP connection uses the address we validated, not a later DNS lookup. -function makeClientCfg(account, resolved, { enableIdle = false } = {}) { - const tlsOpts = { rejectUnauthorized: !account.imap_skip_tls_verify }; +// policy: result of getConnectionPolicy() — gates TLS verification override. +export function makeClientCfg(account, resolved, { enableIdle = false, policy = {} } = {}) { + if (!policy.allowInsecureTls && !account.imap_tls) { + throw new Error('Plain-text IMAP is not allowed: admin must enable "Allow insecure TLS"'); + } + const skipTls = policy.allowInsecureTls && !!account.imap_skip_tls_verify; + const tlsOpts = { rejectUnauthorized: !skipTls }; // Set servername so TLS SNI and cert verification use the original hostname even // though the socket connects directly to the pinned IP address. if (resolved.servername) tlsOpts.servername = resolved.servername; @@ -455,8 +469,8 @@ async function acquirePooledClient(account) { // Grow pool if under limit — refresh token before creating a new connection if (pool.clients.length < POOL_SIZE) { const freshAccount = await ensureFreshToken(account); - const resolved = await resolveForConnection(freshAccount.imap_host); - const client = new ImapFlow(makeClientCfg(freshAccount, resolved)); + const { resolved, policy } = await resolveAccountHost(freshAccount); + const client = new ImapFlow(makeClientCfg(freshAccount, resolved, { policy })); await Promise.race([ client.connect(), new Promise((_, reject) => @@ -488,8 +502,8 @@ async function acquirePooledClient(account) { pool.waiters = pool.waiters.filter(w => w !== entry); try { const freshAccount = await ensureFreshToken(account); - const resolved = await resolveForConnection(freshAccount.imap_host); - const tmp = new ImapFlow(makeClientCfg(freshAccount, resolved)); + const { resolved, policy } = await resolveAccountHost(freshAccount); + const tmp = new ImapFlow(makeClientCfg(freshAccount, resolved, { policy })); tmp.on('error', (err) => { console.error(`IMAP temp client error for account ${account.id}:`, err.message); }); @@ -675,9 +689,10 @@ export class ImapManager { // Refresh OAuth token if needed before connecting account = await ensureFreshToken(account); - const resolved = await resolveForConnection(account.imap_host); - const client = new ImapFlow(makeClientCfg(account, resolved, { enableIdle: true })); + const { resolved, policy } = await resolveAccountHost(account); + let client; try { + client = new ImapFlow(makeClientCfg(account, resolved, { enableIdle: true, policy })); // Race the connect against a 30-second timeout. // client.connect() has no built-in connection timeout — on slow or unresponsive // IMAP servers (e.g. purelymail.com during cold starts) it can hang indefinitely, @@ -805,8 +820,8 @@ export class ImapManager { if (!accountResult.rows.length) return; const freshAccount = await ensureFreshToken(accountResult.rows[0]); syncAccount = freshAccount; - const resolved = await resolveForConnection(freshAccount.imap_host); - activeClient = new ImapFlow(makeClientCfg(freshAccount, resolved, { enableIdle: true })); + const { resolved, policy } = await resolveAccountHost(freshAccount); + activeClient = new ImapFlow(makeClientCfg(freshAccount, resolved, { enableIdle: true, policy })); await Promise.race([ activeClient.connect(), new Promise((_, reject) => @@ -1326,8 +1341,8 @@ export class ImapManager { const row = (await query('SELECT * FROM email_accounts WHERE id = $1', [account.id])).rows[0]; if (!row) throw new Error('Account deleted'); const fresh = await ensureFreshToken(row); - const resolved = await resolveForConnection(fresh.imap_host); - const newClient = new ImapFlow(makeClientCfg(fresh, resolved)); + const { resolved, policy } = await resolveAccountHost(fresh); + const newClient = new ImapFlow(makeClientCfg(fresh, resolved, { policy })); newClient.on('error', (err) => { console.error(`Backfill IMAP error for ${logAccount(account)}:`, err.message); }); @@ -1743,8 +1758,8 @@ export class ImapManager { const row = (await query('SELECT * FROM email_accounts WHERE id = $1', [account.id])).rows[0]; if (!row) throw new Error('Account deleted'); const fresh = await ensureFreshToken(row); - const resolved = await resolveForConnection(fresh.imap_host); - const c = new ImapFlow(makeClientCfg(fresh, resolved)); + const { resolved, policy } = await resolveAccountHost(fresh); + const c = new ImapFlow(makeClientCfg(fresh, resolved, { policy })); c.on('error', err => console.error(`Snippet indexer IMAP error ${logAccount(account)}:`, err.message)); await Promise.race([ c.connect(), diff --git a/backend/src/services/imapManager.test.js b/backend/src/services/imapManager.test.js index e6ac20e..e672f21 100644 --- a/backend/src/services/imapManager.test.js +++ b/backend/src/services/imapManager.test.js @@ -10,10 +10,13 @@ vi.mock('./pushNotifications.js', () => ({ sendPushToUser: vi.fn() })); vi.mock('../utils/redact.js', () => ({ redactEmail: vi.fn() })); vi.mock('./hostValidation.js', () => ({ resolveForConnection: vi.fn() })); -import { providerProfile } from './imapManager.js'; +import { providerProfile, makeClientCfg } from './imapManager.js'; const account = (imap_host, oauth_provider = null) => ({ imap_host, oauth_provider }); +const resolved = { host: '127.0.0.1', servername: null }; +const baseAccount = { imap_host: '127.0.0.1', imap_port: 1143, imap_tls: true, imap_skip_tls_verify: false, auth_user: 'user', auth_pass: 'enc' }; + // ── providerProfile — host detection ───────────────────────────────────────── describe('providerProfile — host detection', () => { @@ -122,3 +125,80 @@ describe('providerProfile — robustness', () => { expect(providerProfile(account('IMAP.GMAIL.COM')).pushesFlags).toBe(false); }); }); + +// ── makeClientCfg — TLS enforcement ────────────────────────────────────────── + +describe('makeClientCfg — TLS enforcement', () => { + it('throws for plain-text IMAP when allowInsecureTls is false', () => { + expect(() => + makeClientCfg({ ...baseAccount, imap_tls: false }, resolved, { policy: { allowInsecureTls: false } }) + ).toThrow(/plain-text IMAP/i); + }); + + it('throws for plain-text IMAP when policy is empty (default)', () => { + expect(() => + makeClientCfg({ ...baseAccount, imap_tls: false }, resolved) + ).toThrow(/plain-text IMAP/i); + }); + + it('does not throw for plain-text IMAP when allowInsecureTls is true', () => { + expect(() => + makeClientCfg({ ...baseAccount, imap_tls: false }, resolved, { policy: { allowInsecureTls: true } }) + ).not.toThrow(); + }); + + it('does not throw for TLS IMAP regardless of allowInsecureTls', () => { + expect(() => + makeClientCfg({ ...baseAccount, imap_tls: true }, resolved, { policy: { allowInsecureTls: false } }) + ).not.toThrow(); + expect(() => + makeClientCfg({ ...baseAccount, imap_tls: true }, resolved, { policy: { allowInsecureTls: true } }) + ).not.toThrow(); + }); +}); + +// ── makeClientCfg — rejectUnauthorized ─────────────────────────────────────── + +describe('makeClientCfg — rejectUnauthorized', () => { + it('sets rejectUnauthorized true by default (no policy)', () => { + const cfg = makeClientCfg(baseAccount, resolved); + expect(cfg.tls.rejectUnauthorized).toBe(true); + }); + + it('sets rejectUnauthorized true when allowInsecureTls is false even if skip_tls_verify is set', () => { + const cfg = makeClientCfg( + { ...baseAccount, imap_skip_tls_verify: true }, + resolved, + { policy: { allowInsecureTls: false } } + ); + expect(cfg.tls.rejectUnauthorized).toBe(true); + }); + + it('sets rejectUnauthorized false when allowInsecureTls is true and imap_skip_tls_verify is true', () => { + const cfg = makeClientCfg( + { ...baseAccount, imap_skip_tls_verify: true }, + resolved, + { policy: { allowInsecureTls: true } } + ); + expect(cfg.tls.rejectUnauthorized).toBe(false); + }); + + it('sets rejectUnauthorized true when allowInsecureTls is true but imap_skip_tls_verify is false', () => { + const cfg = makeClientCfg( + { ...baseAccount, imap_skip_tls_verify: false }, + resolved, + { policy: { allowInsecureTls: true } } + ); + expect(cfg.tls.rejectUnauthorized).toBe(true); + }); + + it('sets servername from resolved when present', () => { + const cfg = makeClientCfg(baseAccount, { host: '142.250.80.46', servername: 'imap.gmail.com' }); + expect(cfg.tls.servername).toBe('imap.gmail.com'); + }); + + it('does not set servername when resolved.servername is null', () => { + const cfg = makeClientCfg(baseAccount, resolved); + expect(cfg.tls.servername).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 63e4d8a..f5cf716 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -64,6 +64,17 @@ function AccountForm({ initial, onSave, onCancel }) { const [error, setError] = useState(''); const [showPass, setShowPass] = useState(false); const [selectedPreset, setSelectedPreset] = useState(null); + const [mailPolicy, setMailPolicy] = useState({ allowPrivateHosts: false, allowInsecureTls: false, allowNonstandardPorts: false }); + + useEffect(() => { + api.admin.getSettings() + .then(d => setMailPolicy({ + allowPrivateHosts: d.settings.allow_private_hosts === 'true', + allowInsecureTls: d.settings.allow_insecure_tls === 'true', + allowNonstandardPorts: d.settings.allow_nonstandard_ports === 'true', + })) + .catch(() => {}); + }, []); const set = (key, val) => setForm(f => ({ ...f, [key]: val })); @@ -195,13 +206,39 @@ function AccountForm({ initial, onSave, onCancel }) { onBlur={e => e.target.style.borderColor = 'var(--border)'} /> - set('imap_port', parseInt(e.target.value))} + set('imap_port', mailPolicy.allowNonstandardPorts ? e.target.value : parseInt(e.target.value))} style={inputStyle} onFocus={e => e.target.style.borderColor = 'var(--accent)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> + {mailPolicy.allowInsecureTls && ( +
+ +
+
{t('admin.accounts.skipTlsVerify')}
+
{t('admin.accounts.skipTlsVerifyDesc')}
+
+
+ )} + {isMicrosoftImapHost(form.imap_host) && (
e.target.style.borderColor = 'var(--border)'} /> - set('smtp_port', parseInt(e.target.value))} + set('smtp_port', mailPolicy.allowNonstandardPorts ? e.target.value : parseInt(e.target.value))} style={inputStyle} onFocus={e => e.target.style.borderColor = 'var(--accent)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} /> @@ -4973,6 +5013,11 @@ function SecurityTab() { const [protectionSaved, setProtectionSaved] = useState(false); const [protectionError, setProtectionError] = useState(''); + // Admin-only: self-hosted mail server policy + const [allowPrivateHosts, setAllowPrivateHosts] = useState(false); + const [allowInsecureTls, setAllowInsecureTls] = useState(false); + const [allowNonstandardPorts, setAllowNonstandardPorts] = useState(false); + // Admin-only: auth activity log const [authEvents, setAuthEvents] = useState([]); const [eventsLoading, setEventsLoading] = useState(false); @@ -4983,6 +5028,9 @@ function SecurityTab() { .then(d => { if (d.settings.auth_max_attempts) setMaxAttempts(parseInt(d.settings.auth_max_attempts)); if (d.settings.auth_window_minutes) setWindowMins(parseInt(d.settings.auth_window_minutes)); + setAllowPrivateHosts(d.settings.allow_private_hosts === 'true'); + setAllowInsecureTls(d.settings.allow_insecure_tls === 'true'); + setAllowNonstandardPorts(d.settings.allow_nonstandard_ports === 'true'); }) .catch(console.error); loadAuthEvents(); @@ -5020,6 +5068,10 @@ function SecurityTab() { } }; + const toggleMailPolicy = async (key, newVal) => { + await api.admin.updateSettings({ [key]: newVal }).catch(console.error); + }; + const eventLabel = (type) => { const map = { login_success: t('admin.security.eventLoginSuccess'), @@ -5161,6 +5213,47 @@ function SecurityTab() {
)} + {/* Mail server connection policy — admin only */} + {user?.isAdmin && ( +
+
+ {t('admin.security.mailPolicyTitle')} +
+
+ {t('admin.security.mailPolicyDesc')} +
+ {[ + { key: 'allow_private_hosts', val: allowPrivateHosts, set: setAllowPrivateHosts, label: t('admin.security.allowPrivateHosts'), desc: t('admin.security.allowPrivateHostsDesc') }, + { key: 'allow_insecure_tls', val: allowInsecureTls, set: setAllowInsecureTls, label: t('admin.security.allowInsecureTls'), desc: t('admin.security.allowInsecureTlsDesc') }, + { key: 'allow_nonstandard_ports', val: allowNonstandardPorts, set: setAllowNonstandardPorts, label: t('admin.security.allowNonstandardPorts'), desc: t('admin.security.allowNonstandardPortsDesc') }, + ].map(({ key, val, set, label, desc }) => ( +
+ +
+
{label}
+
{desc}
+
+
+ ))} +
+ )} + {/* Status card */}