E-Mail-Zugang Test (IMAP + SMTP) in Provider-Einstellungen
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) <noreply@anthropic.com>
This commit is contained in:
@@ -508,9 +508,24 @@ export async function downloadAttachment(req: Request, res: Response): Promise<v
|
||||
res.send(attachment.content);
|
||||
} catch (error) {
|
||||
console.error('downloadAttachment error:', error);
|
||||
const rawMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
const lower = rawMsg.toLowerCase();
|
||||
|
||||
let friendly = rawMsg;
|
||||
if (lower.includes('socket disconnected') && lower.includes('tls')) {
|
||||
friendly =
|
||||
'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
|
||||
} else if (lower.includes('econnrefused')) {
|
||||
friendly = 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
|
||||
} else if (lower.includes('etimedout')) {
|
||||
friendly = 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
|
||||
} else if (lower.includes('authentication') || lower.includes('auth')) {
|
||||
friendly = 'IMAP-Authentifizierung fehlgeschlagen. Bitte Zugangsdaten prüfen.';
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Herunterladen des Anhangs',
|
||||
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Request, Response } from 'express';
|
||||
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
|
||||
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ==================== CONFIG CRUD ====================
|
||||
|
||||
@@ -122,6 +128,156 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const { localPart } = req.params;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -360,6 +360,41 @@ export async function fetchAttachment(
|
||||
uid: number,
|
||||
attachmentFilename: string,
|
||||
folder: string = 'INBOX'
|
||||
): Promise<EmailAttachmentData | null> {
|
||||
// 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<EmailAttachmentData | null> {
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user