From 3eaa511093b967cf7aaba4a212d1e2b46dab21de Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Wed, 3 Jun 2026 09:46:47 +0100 Subject: [PATCH 01/10] feat: admin-controlled policy for self-hosted mail server connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-account allow_private_host toggle with three server-level policy flags set by admins in Settings → Security: - allow_private_hosts: permits IMAP/SMTP on private/local addresses - allow_insecure_tls: exposes per-account TLS verify skip toggle - allow_nonstandard_ports: unlocks free-form port input Migration 0013 seeds the three keys in system_settings (default false) and drops the account-level column added in the earlier draft. A new connectionPolicy service reads the flags for use in validation and connection code. The account form shows TLS and port options only when the admin has enabled them. --- .../0013_account_allow_private_host.sql | 7 + backend/src/routes/accounts.js | 19 ++- backend/src/routes/admin.js | 19 ++- backend/src/routes/send.js | 6 +- backend/src/services/connectionPolicy.js | 18 +++ backend/src/services/hostValidation.js | 31 +++-- backend/src/services/hostValidation.test.js | 45 +++++++ backend/src/services/imapManager.js | 39 ++++-- frontend/src/components/AdminPanel.jsx | 126 +++++++++++++++++- frontend/src/locales/de.json | 12 +- frontend/src/locales/en.json | 12 +- frontend/src/locales/es.json | 2 + frontend/src/locales/fr.json | 2 + frontend/src/locales/it.json | 2 + frontend/src/locales/ru.json | 2 + frontend/src/locales/zhCN.json | 2 + 16 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 backend/migrations/0013_account_allow_private_host.sql create mode 100644 backend/src/services/connectionPolicy.js diff --git a/backend/migrations/0013_account_allow_private_host.sql b/backend/migrations/0013_account_allow_private_host.sql new file mode 100644 index 00000000..7a0ef2cc --- /dev/null +++ b/backend/migrations/0013_account_allow_private_host.sql @@ -0,0 +1,7 @@ +-- Remove account-level column added in earlier draft of this migration +ALTER TABLE email_accounts DROP COLUMN IF EXISTS allow_private_host; + +-- 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 e78bbe76..a8116d9f 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 7a2c5633..58eb59c0 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -95,7 +95,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 +160,20 @@ 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}`); + } + } res.json({ ok: true }); }); @@ -256,7 +271,7 @@ 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 acctResolved = await resolveForConnection(account.smtp_host, { allowPrivate: !!account.allow_private_host }); const acctTls = { rejectUnauthorized: !account.imap_skip_tls_verify }; if (acctResolved.servername) acctTls.servername = acctResolved.servername; transport = nodemailer.createTransport({ diff --git a/backend/src/routes/send.js b/backend/src/routes/send.js index 7761644e..f40bc754 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,9 @@ 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 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 00000000..c46a254e --- /dev/null +++ b/backend/src/services/connectionPolicy.js @@ -0,0 +1,18 @@ +import { query } from './db.js'; + +// Reads the three server-level connection policy flags from system_settings. +// Called once per connection attempt — keeps validation and connection code +// free of direct DB coupling. +export async function getConnectionPolicy() { + 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'; + return { + allowPrivateHosts: !!map.allow_private_hosts, + allowInsecureTls: !!map.allow_insecure_tls, + allowNonstandardPorts: !!map.allow_nonstandard_ports, + }; +} diff --git a/backend/src/services/hostValidation.js b/backend/src/services/hostValidation.js index a8c5c3b8..22c392f2 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 c4a7456d..3577b963 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 32b15b21..174aca64 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,10 @@ 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. +function makeClientCfg(account, resolved, { enableIdle = false, policy = {} } = {}) { + 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 +466,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 +499,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,8 +686,8 @@ 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); + const client = new ImapFlow(makeClientCfg(account, resolved, { enableIdle: true, policy })); try { // Race the connect against a 30-second timeout. // client.connect() has no built-in connection timeout — on slow or unresponsive @@ -805,8 +816,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 +1337,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 +1754,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/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 63e4d8ad..b4bed420 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,13 @@ 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); + const [mailPolicySaving, setMailPolicySaving] = useState(false); + const [mailPolicySaved, setMailPolicySaved] = useState(false); + // Admin-only: auth activity log const [authEvents, setAuthEvents] = useState([]); const [eventsLoading, setEventsLoading] = useState(false); @@ -4983,6 +5030,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 +5070,23 @@ function SecurityTab() { } }; + const saveMailPolicy = async () => { + setMailPolicySaving(true); + try { + await api.admin.updateSettings({ + allow_private_hosts: allowPrivateHosts, + allow_insecure_tls: allowInsecureTls, + allow_nonstandard_ports: allowNonstandardPorts, + }); + setMailPolicySaved(true); + setTimeout(() => setMailPolicySaved(false), 3000); + } catch (err) { + console.error(err); + } finally { + setMailPolicySaving(false); + } + }; + const eventLabel = (type) => { const map = { login_success: t('admin.security.eventLoginSuccess'), @@ -5161,6 +5228,61 @@ function SecurityTab() {
)} + {/* Mail server connection policy — admin only */} + {user?.isAdmin && ( +
+
+ {t('admin.security.mailPolicyTitle')} +
+
+ {t('admin.security.mailPolicyDesc')} +
+ {[ + { key: 'allowPrivateHosts', val: allowPrivateHosts, set: setAllowPrivateHosts, label: t('admin.security.allowPrivateHosts'), desc: t('admin.security.allowPrivateHostsDesc') }, + { key: 'allowInsecureTls', val: allowInsecureTls, set: setAllowInsecureTls, label: t('admin.security.allowInsecureTls'), desc: t('admin.security.allowInsecureTlsDesc') }, + { key: 'allowNonstandardPorts', val: allowNonstandardPorts, set: setAllowNonstandardPorts, label: t('admin.security.allowNonstandardPorts'), desc: t('admin.security.allowNonstandardPortsDesc') }, + ].map(({ key, val, set, label, desc }) => ( +
+ +
+
{label}
+
{desc}
+
+
+ ))} + +
+ )} + {/* Status card */}
Date: Wed, 3 Jun 2026 11:20:03 +0100 Subject: [PATCH 02/10] fix: enforce TLS policy at connection time and auto-save policy toggles - makeClientCfg throws when imap_tls=false and allowInsecureTls policy is off, ensuring plain-text IMAP is rejected when admin requires secure connections - Move makeClientCfg call inside try/catch so policy errors set sync_error and broadcast account_error to the UI instead of being silently swallowed - Block plain-text SMTP when allowInsecureTls is off (STARTTLS and SSL pass) - Replace Save button on mail policy toggles with auto-save on click, matching the existing pattern used by other boolean admin settings - Export makeClientCfg and add tests covering TLS enforcement and rejectUnauthorized behaviour under all policy combinations --- backend/src/routes/send.js | 4 ++ backend/src/services/imapManager.js | 8 ++- backend/src/services/imapManager.test.js | 82 +++++++++++++++++++++++- frontend/src/components/AdminPanel.jsx | 41 ++---------- 4 files changed, 97 insertions(+), 38 deletions(-) diff --git a/backend/src/routes/send.js b/backend/src/routes/send.js index f40bc754..fd7f1b41 100644 --- a/backend/src/routes/send.js +++ b/backend/src/routes/send.js @@ -229,6 +229,10 @@ router.post('/send', async (req, res) => { 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. diff --git a/backend/src/services/imapManager.js b/backend/src/services/imapManager.js index 174aca64..899bd8fd 100644 --- a/backend/src/services/imapManager.js +++ b/backend/src/services/imapManager.js @@ -404,7 +404,10 @@ 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. // policy: result of getConnectionPolicy() — gates TLS verification override. -function makeClientCfg(account, resolved, { enableIdle = false, policy = {} } = {}) { +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 @@ -687,8 +690,9 @@ export class ImapManager { // Refresh OAuth token if needed before connecting account = await ensureFreshToken(account); const { resolved, policy } = await resolveAccountHost(account); - const client = new ImapFlow(makeClientCfg(account, resolved, { enableIdle: true, policy })); + 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, diff --git a/backend/src/services/imapManager.test.js b/backend/src/services/imapManager.test.js index e6ac20e6..e672f217 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 b4bed420..f5cf716c 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -5017,8 +5017,6 @@ function SecurityTab() { const [allowPrivateHosts, setAllowPrivateHosts] = useState(false); const [allowInsecureTls, setAllowInsecureTls] = useState(false); const [allowNonstandardPorts, setAllowNonstandardPorts] = useState(false); - const [mailPolicySaving, setMailPolicySaving] = useState(false); - const [mailPolicySaved, setMailPolicySaved] = useState(false); // Admin-only: auth activity log const [authEvents, setAuthEvents] = useState([]); @@ -5070,21 +5068,8 @@ function SecurityTab() { } }; - const saveMailPolicy = async () => { - setMailPolicySaving(true); - try { - await api.admin.updateSettings({ - allow_private_hosts: allowPrivateHosts, - allow_insecure_tls: allowInsecureTls, - allow_nonstandard_ports: allowNonstandardPorts, - }); - setMailPolicySaved(true); - setTimeout(() => setMailPolicySaved(false), 3000); - } catch (err) { - console.error(err); - } finally { - setMailPolicySaving(false); - } + const toggleMailPolicy = async (key, newVal) => { + await api.admin.updateSettings({ [key]: newVal }).catch(console.error); }; const eventLabel = (type) => { @@ -5241,14 +5226,14 @@ function SecurityTab() { {t('admin.security.mailPolicyDesc')}
{[ - { key: 'allowPrivateHosts', val: allowPrivateHosts, set: setAllowPrivateHosts, label: t('admin.security.allowPrivateHosts'), desc: t('admin.security.allowPrivateHostsDesc') }, - { key: 'allowInsecureTls', val: allowInsecureTls, set: setAllowInsecureTls, label: t('admin.security.allowInsecureTls'), desc: t('admin.security.allowInsecureTlsDesc') }, - { key: 'allowNonstandardPorts', val: allowNonstandardPorts, set: setAllowNonstandardPorts, label: t('admin.security.allowNonstandardPorts'), desc: t('admin.security.allowNonstandardPortsDesc') }, + { 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 }) => (
))} - )} From 617a14df4cd2de134f6c0fa7a2348d6386bdae85 Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Wed, 3 Jun 2026 11:21:25 +0100 Subject: [PATCH 03/10] chore: remove unnecessary DROP COLUMN from migration 0013 --- backend/migrations/0013_account_allow_private_host.sql | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/migrations/0013_account_allow_private_host.sql b/backend/migrations/0013_account_allow_private_host.sql index 7a0ef2cc..e02d0df8 100644 --- a/backend/migrations/0013_account_allow_private_host.sql +++ b/backend/migrations/0013_account_allow_private_host.sql @@ -1,6 +1,3 @@ --- Remove account-level column added in earlier draft of this migration -ALTER TABLE email_accounts DROP COLUMN IF EXISTS allow_private_host; - -- 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; From 78033e9c628627ef3cb35b3129fbecd5f4d092d6 Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Sun, 14 Jun 2026 08:30:52 +0100 Subject: [PATCH 04/10] fix: rename migration 0013 to 0014 to avoid collision with upstream block_list migration --- ...allow_private_host.sql => 0014_account_allow_private_host.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/migrations/{0013_account_allow_private_host.sql => 0014_account_allow_private_host.sql} (100%) diff --git a/backend/migrations/0013_account_allow_private_host.sql b/backend/migrations/0014_account_allow_private_host.sql similarity index 100% rename from backend/migrations/0013_account_allow_private_host.sql rename to backend/migrations/0014_account_allow_private_host.sql From b7c1e58936c8dd95dde2229ea1e605d64206c0b9 Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Sun, 14 Jun 2026 08:09:32 +0100 Subject: [PATCH 05/10] fix: address maintainer review on allow-private-host PR - admin.js: replace dead account.allow_private_host field with system policy in the invite-email SMTP fallback path; also enforce allowInsecureTls policy on rejectUnauthorized (was ignored entirely) - connectionPolicy: add 30s TTL cache to avoid a DB hit on every IMAP connection; expose invalidateConnectionPolicyCache() called from PATCH /settings so changes take effect immediately --- backend/src/routes/admin.js | 7 +++++-- backend/src/services/connectionPolicy.js | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 58eb59c0..6d864265 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(); @@ -174,6 +175,7 @@ router.patch('/settings', async (req, res) => { console.log(`[admin] ${req.session.username} set ${key}=${val}`); } } + invalidateConnectionPolicyCache(); res.json({ ok: true }); }); @@ -271,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, { allowPrivate: !!account.allow_private_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/services/connectionPolicy.js b/backend/src/services/connectionPolicy.js index c46a254e..bf7a4b54 100644 --- a/backend/src/services/connectionPolicy.js +++ b/backend/src/services/connectionPolicy.js @@ -1,18 +1,28 @@ import { query } from './db.js'; -// Reads the three server-level connection policy flags from system_settings. -// Called once per connection attempt — keeps validation and connection code -// free of direct DB coupling. +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'; - return { + _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; } From cab98fa1fc68ab206114c43c00e9940c37fb4bbd Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Sun, 14 Jun 2026 08:14:28 +0100 Subject: [PATCH 06/10] docs: add Upgrading section for v1.5.0 TLS policy breaking change --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index c057cbad..00d7c22a 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,18 @@ nginx (frontend container — internal only) --- +## Upgrading + +### v1.5.0 — 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 From 5bd2a5e230e56176a726c911f02651a88ab83b5b Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Sun, 14 Jun 2026 08:19:07 +0100 Subject: [PATCH 07/10] docs: remove hardcoded version from upgrading section heading --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 00d7c22a..5201d46c 100644 --- a/README.md +++ b/README.md @@ -507,7 +507,7 @@ nginx (frontend container — internal only) ## Upgrading -### v1.5.0 — Mail Server Connection Policy +### Mail Server Connection Policy **Breaking change for accounts with "Skip TLS verification" enabled.** From 5aa8eaa02f9215da155c3d85e39436d1950d9f95 Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Sun, 14 Jun 2026 08:23:32 +0100 Subject: [PATCH 08/10] i18n: add mail policy translations for es, fr, it, ru, zhCN --- frontend/src/locales/es.json | 10 +++++++++- frontend/src/locales/fr.json | 10 +++++++++- frontend/src/locales/it.json | 10 +++++++++- frontend/src/locales/ru.json | 10 +++++++++- frontend/src/locales/zhCN.json | 10 +++++++++- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 5db95887..8f997230 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -679,7 +679,15 @@ "eventSsoLogin": "Inicio de sesión SSO", "activityTitle": "Actividad de inicio de sesión", "loginProtectionTitle": "Protección de inicio de sesión", - "qrCodeAlt": "QR code" + "qrCodeAlt": "QR code", + "mailPolicyTitle": "Política de conexión del servidor de correo", + "mailPolicyDesc": "Controla qué tipos de conexión pueden configurar los usuarios. Deshabilitar para implementaciones con acceso a Internet.", + "allowPrivateHosts": "Permitir hosts privados / locales", + "allowPrivateHostsDesc": "Permite conectarse a servidores IMAP y SMTP en direcciones privadas o locales (p. ej. 127.0.0.1, 192.168.x.x). Necesario para protonmail-bridge y puentes de correo locales similares.", + "allowInsecureTls": "Permitir TLS no seguro", + "allowInsecureTlsDesc": "Permite a los usuarios desactivar la verificación del certificado TLS por cuenta. Necesario para servidores con certificados autofirmados.", + "allowNonstandardPorts": "Permitir puertos no estándar", + "allowNonstandardPortsDesc": "Permite cualquier puerto IMAP o SMTP, no solo 143/993 (IMAP) y 465/587 (SMTP). Necesario para servidores como protonmail-bridge que usan puertos no estándar." }, "privacy": { "title": "Privacidad", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index b86519dd..90930a15 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -679,7 +679,15 @@ "eventSsoLogin": "Connexion SSO", "activityTitle": "Activité de connexion", "loginProtectionTitle": "Protection de connexion", - "qrCodeAlt": "code QR" + "qrCodeAlt": "code QR", + "mailPolicyTitle": "Politique de connexion au serveur de messagerie", + "mailPolicyDesc": "Contrôle les types de connexion que les utilisateurs peuvent configurer. Désactiver pour les déploiements exposés à Internet.", + "allowPrivateHosts": "Autoriser les hôtes privés / locaux", + "allowPrivateHostsDesc": "Permet de se connecter à des serveurs IMAP et SMTP sur des adresses privées ou locales (p. ex. 127.0.0.1, 192.168.x.x). Nécessaire pour protonmail-bridge et les passerelles de messagerie locales similaires.", + "allowInsecureTls": "Autoriser le TLS non sécurisé", + "allowInsecureTlsDesc": "Permet aux utilisateurs de désactiver la vérification du certificat TLS par compte. Nécessaire pour les serveurs avec des certificats auto-signés.", + "allowNonstandardPorts": "Autoriser les ports non standard", + "allowNonstandardPortsDesc": "Autorise n'importe quel port IMAP ou SMTP, pas seulement 143/993 (IMAP) et 465/587 (SMTP). Nécessaire pour les serveurs comme protonmail-bridge qui utilisent des ports non standard." }, "privacy": { "title": "Confidentialité", diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 2e155bb9..18248b3a 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -679,7 +679,15 @@ "eventSsoLogin": "Accesso SSO", "activityTitle": "Attività di accesso", "loginProtectionTitle": "Protezione accesso", - "qrCodeAlt": "codice QR" + "qrCodeAlt": "codice QR", + "mailPolicyTitle": "Politica di connessione al server di posta", + "mailPolicyDesc": "Controlla quali tipi di connessione gli utenti possono configurare. Disabilitare per le distribuzioni accessibili da Internet.", + "allowPrivateHosts": "Consenti host privati / locali", + "allowPrivateHostsDesc": "Consente di connettersi a server IMAP e SMTP a indirizzi privati o locali (es. 127.0.0.1, 192.168.x.x). Necessario per protonmail-bridge e bridge di posta locali simili.", + "allowInsecureTls": "Consenti TLS non sicuro", + "allowInsecureTlsDesc": "Consente agli utenti di disabilitare la verifica del certificato TLS per account. Necessario per server con certificati autofirmati.", + "allowNonstandardPorts": "Consenti porte non standard", + "allowNonstandardPortsDesc": "Consente qualsiasi porta IMAP o SMTP, non solo 143/993 (IMAP) e 465/587 (SMTP). Necessario per server come protonmail-bridge che utilizzano porte non standard." }, "privacy": { "title": "Privacy", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 0a2723d0..9761d82e 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -679,7 +679,15 @@ "eventSsoLogin": "Вход через SSO", "activityTitle": "Активность входа", "loginProtectionTitle": "Защита входа", - "qrCodeAlt": "QR-код" + "qrCodeAlt": "QR-код", + "mailPolicyTitle": "Политика подключения к почтовому серверу", + "mailPolicyDesc": "Управляет типами подключений, которые могут настраивать пользователи. Отключите для развёртываний с доступом в интернет.", + "allowPrivateHosts": "Разрешить частные / локальные хосты", + "allowPrivateHostsDesc": "Разрешает подключение к серверам IMAP и SMTP по частным или локальным адресам (например, 127.0.0.1, 192.168.x.x). Требуется для protonmail-bridge и аналогичных локальных почтовых мостов.", + "allowInsecureTls": "Разрешить небезопасный TLS", + "allowInsecureTlsDesc": "Позволяет пользователям отключить проверку сертификата TLS для каждой учётной записи. Требуется для серверов с самоподписанными сертификатами.", + "allowNonstandardPorts": "Разрешить нестандартные порты", + "allowNonstandardPortsDesc": "Разрешает любые порты IMAP или SMTP, не только 143/993 (IMAP) и 465/587 (SMTP). Требуется для серверов вроде protonmail-bridge, использующих нестандартные порты." }, "privacy": { "title": "Конфиденциальность", diff --git a/frontend/src/locales/zhCN.json b/frontend/src/locales/zhCN.json index 9c55e8a9..8848e2d7 100644 --- a/frontend/src/locales/zhCN.json +++ b/frontend/src/locales/zhCN.json @@ -679,7 +679,15 @@ "eventSsoLogin": "SSO 登录", "activityTitle": "登录活动", "loginProtectionTitle": "登录保护", - "qrCodeAlt": "QR码" + "qrCodeAlt": "QR码", + "mailPolicyTitle": "邮件服务器连接策略", + "mailPolicyDesc": "控制用户可以配置的连接类型。面向互联网的部署请禁用这些选项。", + "allowPrivateHosts": "允许私有/本地主机", + "allowPrivateHostsDesc": "允许连接到私有或本地地址的 IMAP 和 SMTP 服务器(例如 127.0.0.1、192.168.x.x)。protonmail-bridge 及类似本地邮件桥接程序需要此选项。", + "allowInsecureTls": "允许不安全的 TLS", + "allowInsecureTlsDesc": "允许用户按账户禁用 TLS 证书验证。自签名证书的服务器需要此选项。", + "allowNonstandardPorts": "允许非标准端口", + "allowNonstandardPortsDesc": "允许任意 IMAP 或 SMTP 端口,而不仅限于 143/993(IMAP)和 465/587(SMTP)。protonmail-bridge 等使用非标准端口的服务器需要此选项。" }, "privacy": { "title": "隐私", From 2d272534c4cabb0ea12e571a4493dcb39830fd5f Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Sun, 14 Jun 2026 08:37:26 +0100 Subject: [PATCH 09/10] chore: bump qs via npm audit fix (moderate DoS in qs <6.15.1) --- backend/package-lock.json | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 3e321695..9f75bea9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1371,21 +1371,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/brace-expansion": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", @@ -2268,14 +2253,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -2294,7 +2279,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -4070,9 +4055,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" From be66a9c4d92a7c3a7f9b7d06689ba08be3325201 Mon Sep 17 00:00:00 2001 From: saiththerobo Date: Sun, 14 Jun 2026 08:41:24 +0100 Subject: [PATCH 10/10] Revert "chore: bump qs via npm audit fix (moderate DoS in qs <6.15.1)" This reverts commit 2d272534c4cabb0ea12e571a4493dcb39830fd5f. --- backend/package-lock.json | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 9f75bea9..3e321695 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1371,6 +1371,21 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/brace-expansion": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", @@ -2253,14 +2268,14 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.5", + "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -2279,7 +2294,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -4055,9 +4070,9 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0"