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
@@ -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')}
</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]">
{t('email.incoming.save', 'Save Incoming Mail Settings')}
</Button>
+7
View File
@@ -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",
+7
View File
@@ -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",
+10
View File
@@ -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<ImapRoundTripResult>('/admin/email/incoming-config/roundtrip', {});
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> {
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
return response.data;