diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 92173823..23d3bffa 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -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}).` }); } }); diff --git a/backend/src/services/emailIntakeService.js b/backend/src/services/emailIntakeService.js index bcfebb06..28d3949b 100644 --- a/backend/src/services/emailIntakeService.js +++ b/backend/src/services/emailIntakeService.js @@ -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 diff --git a/frontend/src/components/admin/IncomingMailConfigCard.tsx b/frontend/src/components/admin/IncomingMailConfigCard.tsx index 17b15ec7..cc28ed67 100644 --- a/frontend/src/components/admin/IncomingMailConfigCard.tsx +++ b/frontend/src/components/admin/IncomingMailConfigCard.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; 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 { 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.')), }); + 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({ mutationFn: () => emailService.listIncomingFolders(cfg), onSuccess: (list) => { @@ -186,6 +203,17 @@ export const IncomingMailConfigCard: React.FC = () => { > {t('email.incoming.roundTrip', 'Round-trip test')} + diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bdc155f8..3baf793a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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.", "roundTripOk": "Rundlauf OK — an {{recipient}} zugestellt in {{seconds}}s.", "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", "security": "Sicherheit", "ssl": "SSL/TLS", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 490d422a..6ea28679 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2059,6 +2059,13 @@ "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.", "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", "security": "Security", "ssl": "SSL/TLS", diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index 1c047a08..7524c3c1 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -108,6 +108,11 @@ export interface ImapRoundTripResult { recipient?: string; } +export interface ImapPollResult { + processed?: number; + skipped?: 'disabled' | 'unconfigured' | 'busy'; +} + export interface ReceivedEmail { id: number; message_id: string | null; @@ -162,6 +167,11 @@ export const emailService = { const response = await api.post('/admin/email/incoming-config/roundtrip', {}); return response.data; }, + // Run the poller on demand. Returns { processed } or { skipped: '…' }. + async pollIncoming(): Promise { + const response = await api.post('/admin/email/incoming-config/poll', {}); + return response.data; + }, async listReceived(params: { page?: number; pageSize?: number } = {}): Promise { const response = await api.get('/admin/email/received', { params }); return response.data;