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)'} />