fix(email): fail-fast IMAP timeouts + manual 'Check now' poll

- Root cause of the 502s: ImapFlow had no connect timeout, so a wrong host/port
  (e.g. IMAP on an SMTP port) hung the request until the proxy returned 502 with
  no message. Added connectionTimeout/greetingTimeout/socketTimeout + a hard
  connectWithTimeout() race on every IMAP client (detect/test/roundtrip/poll).
- Error routes now return 422 with the underlying reason (was 502, which
  collided with the proxy's own 502 and hid the message).
- New 'Check now' button + POST /incoming-config/poll runs the poller on demand
  (respects the incomingMail flag) and reports disabled/unconfigured/busy or N
  ingested — so 'nothing in Received' is diagnosable without waiting 60s.
- en/de strings
This commit is contained in:
Luca
2026-06-12 15:08:12 +02:00
parent e258472391
commit 8a54c6f6b1
6 changed files with 103 additions and 12 deletions
+17 -3
View File
@@ -193,7 +193,7 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
res.json({ folders });
} catch (error) {
console.error('IMAP folder detection error:', error);
res.status(502).json({ error: 'Could not connect to the mailbox. Check host, port and credentials.' });
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993) and credentials.` });
}
});
@@ -218,7 +218,7 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
res.json(result);
} catch (error) {
console.error('IMAP connection test error:', error);
res.status(502).json({ error: 'Could not connect to the mailbox. Check host, port, credentials and folder.' });
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993), credentials and folder.` });
}
});
@@ -240,7 +240,21 @@ router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.se
.json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient });
} catch (error) {
console.error('Round-trip test error:', error);
res.status(502).json({ error: 'Round-trip test failed — check both SMTP and IMAP settings.' });
res.status(422).json({ error: `Round-trip test failed (${error.message}) — check both SMTP and IMAP settings.` });
}
});
// Run the incoming-mail poller on demand (instead of waiting for the 60s loop)
// so the admin can verify ingestion + see why nothing arrived. Respects the
// incomingMail flag — a manual run still won't ingest when the feature is off.
router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const emailIntakeService = require('../services/emailIntakeService');
const result = await emailIntakeService.pollOnce();
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
} catch (error) {
console.error('Manual poll error:', error);
res.status(422).json({ error: `Mailbox poll failed (${error.message}).` });
}
});
+33 -8
View File
@@ -20,6 +20,31 @@ const expenseService = require('./expenseService');
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
let polling = false;
// Fail fast instead of hanging on a wrong host/port (e.g. IMAP pointed at an
// SMTP port). Without these, ImapFlow waits indefinitely and the HTTP request
// dies at the proxy as a 502 with no useful message.
const IMAP_TIMEOUTS = { connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 30000 };
function makeImapClient(cfg) {
return new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false, ...IMAP_TIMEOUTS });
}
/** Connect with a hard ceiling, so a stuck TLS handshake can't hang forever. */
async function connectWithTimeout(client, ms = 12000) {
let timer;
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('IMAP connection timed out')), ms); });
try {
await Promise.race([client.connect(), timeout]);
} catch (err) {
// Best-effort teardown if connect lost the race but is still pending.
try { await client.logout(); } catch (_) { /* noop */ }
try { client.close(); } catch (_) { /* noop */ }
throw err;
} finally {
clearTimeout(timer);
}
}
async function isEnabled() {
const flag = await db('feature_flags').where({ key: 'incomingMail' }).first();
return !!(flag && (flag.value === true || flag.value === 1 || flag.value === '1'));
@@ -73,8 +98,8 @@ async function listFolders(override) {
cfg = await getImapConfig();
}
if (!cfg) return [];
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
await client.connect();
const client = makeImapClient(cfg);
await connectWithTimeout(client);
try {
const list = await client.list();
return (list || []).map((m) => ({ path: m.path, name: m.name, specialUse: m.specialUse || null }));
@@ -110,8 +135,8 @@ async function testConnection(override) {
cfg = { host: c.host, port: c.port, secure: c.secure, auth: c.auth };
folder = c.folder;
}
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
await client.connect();
const client = makeImapClient(cfg);
await connectWithTimeout(client);
try {
const status = await client.status(folder, { messages: true, unseen: true });
return { ok: true, folder, messages: status.messages || 0, unseen: status.unseen || 0 };
@@ -169,8 +194,8 @@ async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) {
// 2) Poll IMAP for the tagged message until timeout.
const cfg = await getImapConfig();
const folder = cfg?.folder || 'INBOX';
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
await client.connect();
const client = makeImapClient(cfg);
await connectWithTimeout(client);
const started = Date.now();
try {
// eslint-disable-next-line no-constant-condition
@@ -204,10 +229,10 @@ async function pollOnce() {
if (!cfg) return { skipped: 'unconfigured' };
polling = true;
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
const client = makeImapClient(cfg);
let processed = 0;
try {
await client.connect();
await connectWithTimeout(client);
const lock = await client.getMailboxLock(cfg.folder);
try {
// eslint-disable-next-line no-restricted-syntax