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:
@@ -193,7 +193,7 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
|
|||||||
res.json({ folders });
|
res.json({ folders });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('IMAP folder detection error:', 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);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('IMAP connection test error:', 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 });
|
.json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Round-trip test error:', 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}).` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,31 @@ const expenseService = require('./expenseService');
|
|||||||
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||||
let polling = false;
|
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() {
|
async function isEnabled() {
|
||||||
const flag = await db('feature_flags').where({ key: 'incomingMail' }).first();
|
const flag = await db('feature_flags').where({ key: 'incomingMail' }).first();
|
||||||
return !!(flag && (flag.value === true || flag.value === 1 || flag.value === '1'));
|
return !!(flag && (flag.value === true || flag.value === 1 || flag.value === '1'));
|
||||||
@@ -73,8 +98,8 @@ async function listFolders(override) {
|
|||||||
cfg = await getImapConfig();
|
cfg = await getImapConfig();
|
||||||
}
|
}
|
||||||
if (!cfg) return [];
|
if (!cfg) return [];
|
||||||
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
|
const client = makeImapClient(cfg);
|
||||||
await client.connect();
|
await connectWithTimeout(client);
|
||||||
try {
|
try {
|
||||||
const list = await client.list();
|
const list = await client.list();
|
||||||
return (list || []).map((m) => ({ path: m.path, name: m.name, specialUse: m.specialUse || null }));
|
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 };
|
cfg = { host: c.host, port: c.port, secure: c.secure, auth: c.auth };
|
||||||
folder = c.folder;
|
folder = c.folder;
|
||||||
}
|
}
|
||||||
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
|
const client = makeImapClient(cfg);
|
||||||
await client.connect();
|
await connectWithTimeout(client);
|
||||||
try {
|
try {
|
||||||
const status = await client.status(folder, { messages: true, unseen: true });
|
const status = await client.status(folder, { messages: true, unseen: true });
|
||||||
return { ok: true, folder, messages: status.messages || 0, unseen: status.unseen || 0 };
|
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.
|
// 2) Poll IMAP for the tagged message until timeout.
|
||||||
const cfg = await getImapConfig();
|
const cfg = await getImapConfig();
|
||||||
const folder = cfg?.folder || 'INBOX';
|
const folder = cfg?.folder || 'INBOX';
|
||||||
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
|
const client = makeImapClient(cfg);
|
||||||
await client.connect();
|
await connectWithTimeout(client);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
@@ -204,10 +229,10 @@ async function pollOnce() {
|
|||||||
if (!cfg) return { skipped: 'unconfigured' };
|
if (!cfg) return { skipped: 'unconfigured' };
|
||||||
|
|
||||||
polling = true;
|
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;
|
let processed = 0;
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await connectWithTimeout(client);
|
||||||
const lock = await client.getMailboxLock(cfg.folder);
|
const lock = await client.getMailboxLock(cfg.folder);
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox } from 'lucide-react';
|
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox, RefreshCw } from 'lucide-react';
|
||||||
import { Button, Card, Input, Loading } from '../common';
|
import { Button, Card, Input, Loading } from '../common';
|
||||||
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
|
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
|
||||||
|
|
||||||
@@ -56,6 +56,23 @@ export const IncomingMailConfigCard: React.FC = () => {
|
|||||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.')),
|
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.')),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const poll = useMutation({
|
||||||
|
mutationFn: () => emailService.pollIncoming(),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
if (r.skipped === 'disabled') {
|
||||||
|
toast.info(t('email.incoming.pollDisabled', 'Incoming mail is turned off — enable it under Settings → Features.'));
|
||||||
|
} else if (r.skipped === 'unconfigured') {
|
||||||
|
toast.info(t('email.incoming.pollUnconfigured', 'Save the incoming mail settings first.'));
|
||||||
|
} else if (r.skipped === 'busy') {
|
||||||
|
toast.info(t('email.incoming.pollBusy', 'A poll is already running — try again in a moment.'));
|
||||||
|
} else {
|
||||||
|
toast.success(t('email.incoming.pollOk', 'Checked mailbox — {{count}} new email(s) ingested.', { count: r.processed || 0 }));
|
||||||
|
qc.invalidateQueries({ queryKey: ['received-emails'] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.pollFailed', 'Mailbox poll failed.')),
|
||||||
|
});
|
||||||
|
|
||||||
const detect = useMutation({
|
const detect = useMutation({
|
||||||
mutationFn: () => emailService.listIncomingFolders(cfg),
|
mutationFn: () => emailService.listIncomingFolders(cfg),
|
||||||
onSuccess: (list) => {
|
onSuccess: (list) => {
|
||||||
@@ -186,6 +203,17 @@ export const IncomingMailConfigCard: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('email.incoming.roundTrip', 'Round-trip test')}
|
{t('email.incoming.roundTrip', 'Round-trip test')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => poll.mutate()}
|
||||||
|
isLoading={poll.isPending}
|
||||||
|
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||||
|
leftIcon={<RefreshCw className="w-5 h-5" />}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
title={t('email.incoming.pollHint', 'Check the mailbox now instead of waiting for the 60-second poll. Ingests unread attachments into Incoming invoices.') as string}
|
||||||
|
>
|
||||||
|
{t('email.incoming.poll', 'Check now')}
|
||||||
|
</Button>
|
||||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
|
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
|
||||||
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2486,6 +2486,13 @@
|
|||||||
"roundTripHint": "Sendet über die SMTP-Einstellungen eine Test-E-Mail an dieses Postfach und prüft, ob sie ankommt. Beide vorher speichern.",
|
"roundTripHint": "Sendet über die SMTP-Einstellungen eine Test-E-Mail an dieses Postfach und prüft, ob sie ankommt. Beide vorher speichern.",
|
||||||
"roundTripOk": "Rundlauf OK — an {{recipient}} zugestellt in {{seconds}}s.",
|
"roundTripOk": "Rundlauf OK — an {{recipient}} zugestellt in {{seconds}}s.",
|
||||||
"roundTripFailed": "Rundlauf-Test fehlgeschlagen.",
|
"roundTripFailed": "Rundlauf-Test fehlgeschlagen.",
|
||||||
|
"poll": "Jetzt prüfen",
|
||||||
|
"pollHint": "Postfach sofort prüfen, statt auf den 60-Sekunden-Abruf zu warten. Ungelesene Anhänge werden in Eingangsrechnungen übernommen.",
|
||||||
|
"pollOk": "Postfach geprüft — {{count}} neue E-Mail(s) übernommen.",
|
||||||
|
"pollDisabled": "Eingehende E-Mails sind deaktiviert — unter Einstellungen → Funktionen aktivieren.",
|
||||||
|
"pollUnconfigured": "Zuerst die Einstellungen für eingehende E-Mails speichern.",
|
||||||
|
"pollBusy": "Ein Abruf läuft bereits — bitte gleich erneut versuchen.",
|
||||||
|
"pollFailed": "Postfach-Abruf fehlgeschlagen.",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"security": "Sicherheit",
|
"security": "Sicherheit",
|
||||||
"ssl": "SSL/TLS",
|
"ssl": "SSL/TLS",
|
||||||
|
|||||||
@@ -2059,6 +2059,13 @@
|
|||||||
"roundTripHint": "Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.",
|
"roundTripHint": "Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.",
|
||||||
"roundTripOk": "Round-trip OK — delivered to {{recipient}} in {{seconds}}s.",
|
"roundTripOk": "Round-trip OK — delivered to {{recipient}} in {{seconds}}s.",
|
||||||
"roundTripFailed": "Round-trip test failed.",
|
"roundTripFailed": "Round-trip test failed.",
|
||||||
|
"poll": "Check now",
|
||||||
|
"pollHint": "Check the mailbox now instead of waiting for the 60-second poll. Ingests unread attachments into Incoming invoices.",
|
||||||
|
"pollOk": "Checked mailbox — {{count}} new email(s) ingested.",
|
||||||
|
"pollDisabled": "Incoming mail is turned off — enable it under Settings → Features.",
|
||||||
|
"pollUnconfigured": "Save the incoming mail settings first.",
|
||||||
|
"pollBusy": "A poll is already running — try again in a moment.",
|
||||||
|
"pollFailed": "Mailbox poll failed.",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
"ssl": "SSL/TLS",
|
"ssl": "SSL/TLS",
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ export interface ImapRoundTripResult {
|
|||||||
recipient?: string;
|
recipient?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImapPollResult {
|
||||||
|
processed?: number;
|
||||||
|
skipped?: 'disabled' | 'unconfigured' | 'busy';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReceivedEmail {
|
export interface ReceivedEmail {
|
||||||
id: number;
|
id: number;
|
||||||
message_id: string | null;
|
message_id: string | null;
|
||||||
@@ -162,6 +167,11 @@ export const emailService = {
|
|||||||
const response = await api.post<ImapRoundTripResult>('/admin/email/incoming-config/roundtrip', {});
|
const response = await api.post<ImapRoundTripResult>('/admin/email/incoming-config/roundtrip', {});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
// Run the poller on demand. Returns { processed } or { skipped: '…' }.
|
||||||
|
async pollIncoming(): Promise<ImapPollResult> {
|
||||||
|
const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
||||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user