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:
2026-04-23 14:59:06 +02:00
parent 4c65353917
commit b76ca9fd7f
6 changed files with 413 additions and 20 deletions
+87 -19
View File
@@ -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();