From fd55f3129f38ca2ecddb351e833e910bd5d0bd78 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 23 Apr 2026 14:59:06 +0200 Subject: [PATCH] E-Mail-Zugang Test (IMAP + SMTP) in Provider-Einstellungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das bestehende „Verbindung testen" prüft nur den API-Zugang (Plesk/cPanel), nicht den eigentlichen IMAP/SMTP-Zugang der System-E-Mail. Das führte dazu, dass Anhang-Downloads scheiterten obwohl der API-Test grün war. Neuer Button im EmailProviders-Modal: „E-Mail-Zugang testen (IMAP + SMTP)" - Testet IMAP-Empfang und SMTP-Versand separat - Zeigt pro Protokoll Erfolg oder Fehlermeldung mit Server/Port/Verschlüsselung - Nutzt die hinterlegte System-E-Mail-Adresse + Passwort - Funktioniert auch vor dem ersten Speichern (mit Formulardaten) Außerdem im Anhang-Download: - Retry-Mechanismus bei transienten TLS/Netzwerk-Fehlern (3 Versuche) - Socket-Timeout 30s gegen hängende Verbindungen - Sprechende Fehlermeldungen (z.B. Hinweis auf selbstsigniertes Zertifikat) - Debug-Logging mit Host/Port/User/Folder/UID Backend: - Neuer Endpoint POST /api/email-providers/test-mail-access - fetchAttachment in imapService: Retry-Wrapper + fetchAttachmentInner - Besseres Error-Handling in downloadAttachment (Cert-Hinweis, Auth, Timeout) Frontend: - emailProviderApi.testMailAccess() - EmailProviders-Modal: neuer Button + zweispaltige Ergebnis-Anzeige für IMAP+SMTP Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/controllers/cachedEmail.controller.ts | 17 +- .../controllers/emailProvider.controller.ts | 156 ++++++++++++++++++ backend/src/routes/emailProvider.routes.ts | 1 + backend/src/services/imapService.ts | 106 +++++++++--- .../src/pages/settings/EmailProviders.tsx | 136 +++++++++++++++ frontend/src/services/api.ts | 17 ++ 6 files changed, 413 insertions(+), 20 deletions(-) diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 22612d59..92f257f2 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -508,9 +508,24 @@ export async function downloadAttachment(req: Request, res: Response): Promise } } +/** + * Testet IMAP + SMTP-Zugang für die System-E-Mail eines Providers. + * - Option A: Provider-ID + optional überschreibendes Passwort aus Body (Modal) + * - Option B: Testdaten komplett aus Body (beim Anlegen, noch nicht gespeichert) + */ +export async function testMailAccess(req: Request, res: Response): Promise { + try { + const id = req.body?.id ? parseInt(req.body.id) : undefined; + const bodyEmail = typeof req.body?.systemEmailAddress === 'string' ? req.body.systemEmailAddress : undefined; + const bodyPassword = typeof req.body?.systemEmailPassword === 'string' ? req.body.systemEmailPassword : undefined; + + let emailAddress: string | undefined; + let password: string | undefined; + let smtpServer: string; + let smtpPort: number; + let imapServer: string; + let imapPort: number; + let smtpEncryption: 'SSL' | 'STARTTLS' | 'NONE'; + let imapEncryption: 'SSL' | 'STARTTLS' | 'NONE'; + let allowSelfSignedCerts: boolean; + + if (id) { + // Gespeicherten Provider laden + const config = await prisma.emailProviderConfig.findUnique({ where: { id } }); + if (!config) { + res.status(404).json({ success: false, error: 'Provider nicht gefunden' } as ApiResponse); + return; + } + + emailAddress = bodyEmail || config.systemEmailAddress || undefined; + if (bodyPassword) { + password = bodyPassword; + } else if (config.systemEmailPasswordEncrypted) { + try { + password = decrypt(config.systemEmailPasswordEncrypted); + } catch { + password = undefined; + } + } + + // IMAP/SMTP-Settings vom Provider ableiten + const settings = await emailProviderService.getImapSmtpSettings(); + if (!settings) { + res.status(400).json({ success: false, error: 'Keine IMAP/SMTP-Einstellungen verfügbar' } as ApiResponse); + return; + } + smtpServer = settings.smtpServer; + smtpPort = settings.smtpPort; + imapServer = settings.imapServer; + imapPort = settings.imapPort; + smtpEncryption = settings.smtpEncryption; + imapEncryption = settings.imapEncryption; + allowSelfSignedCerts = settings.allowSelfSignedCerts; + } else if (req.body?.apiUrl) { + // Formulardaten ohne gespeicherten Provider + emailAddress = bodyEmail; + password = bodyPassword; + + try { + const url = new URL(req.body.apiUrl); + smtpServer = url.hostname; + imapServer = url.hostname; + } catch { + smtpServer = `mail.${req.body.domain || ''}`; + imapServer = smtpServer; + } + + imapEncryption = (req.body.imapEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE'; + smtpEncryption = (req.body.smtpEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE'; + allowSelfSignedCerts = !!req.body.allowSelfSignedCerts; + + imapPort = imapEncryption === 'SSL' ? 993 : 143; + smtpPort = smtpEncryption === 'SSL' ? 465 : smtpEncryption === 'STARTTLS' ? 587 : 25; + } else { + res.status(400).json({ success: false, error: 'Provider-ID oder Testdaten erforderlich' } as ApiResponse); + return; + } + + if (!emailAddress || !password) { + res.status(400).json({ + success: false, + error: 'System-E-Mail-Adresse und Passwort sind erforderlich', + } as ApiResponse); + return; + } + + // IMAP testen + const imapCredentials: ImapCredentials = { + host: imapServer, + port: imapPort, + user: emailAddress, + password, + encryption: imapEncryption, + allowSelfSignedCerts, + }; + + // SMTP testen + const smtpCredentials: SmtpCredentials = { + host: smtpServer, + port: smtpPort, + user: emailAddress, + password, + encryption: smtpEncryption, + allowSelfSignedCerts, + }; + + let imapResult: { success: boolean; error?: string } = { success: false }; + let smtpResult: { success: boolean; error?: string } = { success: false }; + + try { + await testImapConnection(imapCredentials); + imapResult = { success: true }; + } catch (e) { + imapResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }; + } + + try { + await testSmtpConnection(smtpCredentials); + smtpResult = { success: true }; + } catch (e) { + smtpResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' }; + } + + res.json({ + success: imapResult.success && smtpResult.success, + data: { + imap: { + ...imapResult, + server: imapServer, + port: imapPort, + encryption: imapEncryption, + }, + smtp: { + ...smtpResult, + server: smtpServer, + port: smtpPort, + encryption: smtpEncryption, + }, + user: emailAddress, + }, + } as ApiResponse); + } catch (error) { + console.error('testMailAccess error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Test', + } as ApiResponse); + } +} + export async function checkEmailExists(req: Request, res: Response): Promise { try { const { localPart } = req.params; diff --git a/backend/src/routes/emailProvider.routes.ts b/backend/src/routes/emailProvider.routes.ts index 2124f818..62db111d 100644 --- a/backend/src/routes/emailProvider.routes.ts +++ b/backend/src/routes/emailProvider.routes.ts @@ -15,6 +15,7 @@ router.delete('/configs/:id', authenticate, requirePermission('settings:update') // Email Operations router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection); +router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess); router.get('/domain', authenticate, emailProviderController.getProviderDomain); router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists); router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail); diff --git a/backend/src/services/imapService.ts b/backend/src/services/imapService.ts index 2c1f6a1f..29fc4bc2 100644 --- a/backend/src/services/imapService.ts +++ b/backend/src/services/imapService.ts @@ -360,6 +360,41 @@ export async function fetchAttachment( uid: number, attachmentFilename: string, folder: string = 'INBOX' +): Promise { + // Bei transienten Netzwerkfehlern automatisch bis zu 2x retry + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + return await fetchAttachmentInner(credentials, uid, attachmentFilename, folder); + } catch (err) { + lastError = err; + const msg = err instanceof Error ? err.message.toLowerCase() : ''; + const isTransient = + msg.includes('socket disconnected') || + msg.includes('econnreset') || + msg.includes('etimedout') || + msg.includes('socket hang up') || + msg.includes('network socket'); + + if (!isTransient || attempt === 3) { + throw err; + } + + console.warn( + `[fetchAttachment] Versuch ${attempt}/3 fehlgeschlagen (transient), retry in ${attempt * 500}ms:`, + msg, + ); + await new Promise((r) => setTimeout(r, attempt * 500)); + } + } + throw lastError; +} + +async function fetchAttachmentInner( + credentials: ImapCredentials, + uid: number, + attachmentFilename: string, + folder: string = 'INBOX' ): Promise { // Verschlüsselungs-Einstellungen basierend auf Modus const encryption = credentials.encryption ?? 'SSL'; @@ -374,6 +409,8 @@ export async function fetchAttachment( pass: credentials.password, }, logger: false, + // Timeouts gegen hängende Verbindungen + socketTimeout: 30000, }; if (encryption !== 'NONE') { @@ -382,37 +419,68 @@ export async function fetchAttachment( const client = new ImapFlow(clientOptions); + console.log(`[fetchAttachment] Host: ${credentials.host}:${credentials.port} | User: ${credentials.user} | Folder: ${folder} | UID: ${uid} | File: ${attachmentFilename}`); + try { await client.connect(); - await client.mailboxOpen(folder); + + // Ordner öffnen – bei Fehler alle verfügbaren Ordner listen für Debugging + try { + await client.mailboxOpen(folder); + } catch (folderErr) { + console.error(`[fetchAttachment] mailboxOpen('${folder}') failed:`, folderErr); + try { + const list = await client.list(); + const available = list.map((m) => m.path).join(', '); + console.error(`[fetchAttachment] Verfügbare Ordner: ${available}`); + throw new Error( + `Ordner '${folder}' nicht gefunden. Verfügbar: ${available}`, + ); + } catch (listErr) { + throw folderErr; + } + } // E-Mail per UID abrufen let attachment: EmailAttachmentData | null = null; + let foundMessage = false; // Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird - for await (const message of client.fetch(uid.toString(), { - source: true, - }, { uid: true })) { - if (!message.source) continue; + try { + for await (const message of client.fetch(uid.toString(), { + source: true, + }, { uid: true })) { + foundMessage = true; + if (!message.source) continue; - // E-Mail parsen - const parsed = await simpleParser(message.source); + // E-Mail parsen + const parsed = await simpleParser(message.source); - // Anhang suchen - if (parsed.attachments) { - for (const att of parsed.attachments) { - const filename = att.filename || 'unnamed'; - if (filename === attachmentFilename) { - attachment = { - filename, - content: att.content, - contentType: att.contentType || 'application/octet-stream', - size: att.size, - }; - break; + // Anhang suchen + if (parsed.attachments) { + for (const att of parsed.attachments) { + const filename = att.filename || 'unnamed'; + if (filename === attachmentFilename) { + attachment = { + filename, + content: att.content, + contentType: att.contentType || 'application/octet-stream', + size: att.size, + }; + break; + } } } } + } catch (fetchErr) { + console.error(`[fetchAttachment] fetch(UID ${uid}) failed:`, fetchErr); + throw new Error( + `Nachricht mit UID ${uid} konnte nicht geladen werden (${fetchErr instanceof Error ? fetchErr.message : 'unbekannter Fehler'}). Möglicherweise wurde sie im IMAP-Postfach verschoben oder gelöscht.`, + ); + } + + if (!foundMessage) { + console.warn(`[fetchAttachment] Keine Nachricht mit UID ${uid} in Ordner '${folder}' gefunden`); } await client.logout(); diff --git a/frontend/src/pages/settings/EmailProviders.tsx b/frontend/src/pages/settings/EmailProviders.tsx index 05a17d5b..b5284461 100644 --- a/frontend/src/pages/settings/EmailProviders.tsx +++ b/frontend/src/pages/settings/EmailProviders.tsx @@ -80,6 +80,13 @@ export default function EmailProviders() { // Test-Status pro Provider in der Liste const [providerTestResults, setProviderTestResults] = useState>({}); const [testingProviderId, setTestingProviderId] = useState(null); + // E-Mail-Zugang-Test + const [isTestingMailAccess, setIsTestingMailAccess] = useState(false); + const [mailAccessResult, setMailAccessResult] = useState<{ + imap: { success: boolean; error?: string; server: string; port: number; encryption: string }; + smtp: { success: boolean; error?: string; server: string; port: number; encryption: string }; + user: string; + } | null>(null); const { data: configsData, isLoading } = useQuery({ queryKey: ['email-provider-configs'], @@ -151,6 +158,7 @@ export default function EmailProviders() { setFormData(emptyForm); setShowPassword(false); setModalTestResult(null); + setMailAccessResult(null); }; // Test für einen gespeicherten Provider in der Liste @@ -216,6 +224,57 @@ export default function EmailProviders() { } }; + // IMAP + SMTP-Zugang der System-E-Mail testen + const handleTestMailAccess = async () => { + if (!formData.systemEmailAddress) { + setMailAccessResult({ + imap: { success: false, error: 'System-E-Mail-Adresse fehlt', server: '', port: 0, encryption: '' }, + smtp: { success: false, error: 'System-E-Mail-Adresse fehlt', server: '', port: 0, encryption: '' }, + user: '', + }); + return; + } + + setIsTestingMailAccess(true); + setMailAccessResult(null); + try { + const body: Parameters[0] = editingId + ? { + id: editingId, + systemEmailAddress: formData.systemEmailAddress, + systemEmailPassword: formData.systemEmailPassword || undefined, + } + : { + apiUrl: formData.apiUrl, + domain: formData.domain, + systemEmailAddress: formData.systemEmailAddress, + systemEmailPassword: formData.systemEmailPassword, + imapEncryption: formData.imapEncryption, + smtpEncryption: formData.smtpEncryption, + allowSelfSignedCerts: formData.allowSelfSignedCerts, + }; + + const result = await emailProviderApi.testMailAccess(body); + if (result.data) { + setMailAccessResult(result.data); + } + } catch (error) { + setMailAccessResult({ + imap: { + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Test', + server: '', + port: 0, + encryption: '', + }, + smtp: { success: false, server: '', port: 0, encryption: '' }, + user: '', + }); + } finally { + setIsTestingMailAccess(false); + } + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -659,6 +718,83 @@ export default function EmailProviders() { )} )} + + {/* E-Mail-Zugang testen (IMAP + SMTP) */} +
+

+ Testet den tatsächlichen E-Mail-Zugang (IMAP-Empfang und SMTP-Versand) der System-E-Mail. +

+ + + {mailAccessResult && ( +
+ {/* IMAP-Ergebnis */} +
+ {mailAccessResult.imap.success ? ( +
+ + + IMAP erfolgreich ({mailAccessResult.imap.server}:{mailAccessResult.imap.port}, {mailAccessResult.imap.encryption}) + +
+ ) : ( +
+ +
+ IMAP fehlgeschlagen + {mailAccessResult.imap.server && ( + ({mailAccessResult.imap.server}:{mailAccessResult.imap.port}) + )} + {mailAccessResult.imap.error && ( +
{mailAccessResult.imap.error}
+ )} +
+
+ )} +
+ + {/* SMTP-Ergebnis */} +
+ {mailAccessResult.smtp.success ? ( +
+ + + SMTP erfolgreich ({mailAccessResult.smtp.server}:{mailAccessResult.smtp.port}, {mailAccessResult.smtp.encryption}) + +
+ ) : ( +
+ +
+ SMTP fehlgeschlagen + {mailAccessResult.smtp.server && ( + ({mailAccessResult.smtp.server}:{mailAccessResult.smtp.port}) + )} + {mailAccessResult.smtp.error && ( +
{mailAccessResult.smtp.error}
+ )} +
+
+ )} +
+
+ )} +
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b29c648b..e04aab20 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1254,6 +1254,23 @@ export const emailProviderApi = { const res = await api.post>('/email-providers/test-connection', body); return res.data; }, + testMailAccess: async (body: { + id?: number; + apiUrl?: string; + domain?: string; + systemEmailAddress?: string; + systemEmailPassword?: string; + imapEncryption?: 'SSL' | 'STARTTLS' | 'NONE'; + smtpEncryption?: 'SSL' | 'STARTTLS' | 'NONE'; + allowSelfSignedCerts?: boolean; + }) => { + const res = await api.post>('/email-providers/test-mail-access', body); + return res.data; + }, getDomain: async () => { const res = await api.get>('/email-providers/domain'); return res.data;