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:
duffyduck 2026-04-23 14:59:06 +02:00
parent 109f774d62
commit fd55f3129f
6 changed files with 413 additions and 20 deletions

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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);

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,17 +419,38 @@ 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();
// 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
try {
for await (const message of client.fetch(uid.toString(), {
source: true,
}, { uid: true })) {
foundMessage = true;
if (!message.source) continue;
// E-Mail parsen
@ -414,6 +472,16 @@ export async function fetchAttachment(
}
}
}
} 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();
return attachment;

View File

@ -80,6 +80,13 @@ export default function EmailProviders() {
// Test-Status pro Provider in der Liste
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
const [testingProviderId, setTestingProviderId] = useState<number | null>(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<typeof emailProviderApi.testMailAccess>[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() {
)}
</div>
)}
{/* E-Mail-Zugang testen (IMAP + SMTP) */}
<div className="mt-4">
<p className="text-xs text-gray-500 mb-2">
Testet den tatsächlichen E-Mail-Zugang (IMAP-Empfang und SMTP-Versand) der System-E-Mail.
</p>
<Button
type="button"
variant="secondary"
onClick={handleTestMailAccess}
disabled={isTestingMailAccess || !formData.systemEmailAddress}
className="w-full"
>
{isTestingMailAccess ? (
'Teste IMAP + SMTP...'
) : (
<>
<Mail className="w-4 h-4 mr-2" />
E-Mail-Zugang testen (IMAP + SMTP)
</>
)}
</Button>
{mailAccessResult && (
<div className="mt-2 space-y-2">
{/* IMAP-Ergebnis */}
<div className={`p-3 rounded-lg text-sm ${mailAccessResult.imap.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{mailAccessResult.imap.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>
<strong>IMAP</strong> erfolgreich ({mailAccessResult.imap.server}:{mailAccessResult.imap.port}, {mailAccessResult.imap.encryption})
</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
<strong>IMAP</strong> fehlgeschlagen
{mailAccessResult.imap.server && (
<span className="text-xs opacity-75"> ({mailAccessResult.imap.server}:{mailAccessResult.imap.port})</span>
)}
{mailAccessResult.imap.error && (
<div className="mt-1 text-xs">{mailAccessResult.imap.error}</div>
)}
</div>
</div>
)}
</div>
{/* SMTP-Ergebnis */}
<div className={`p-3 rounded-lg text-sm ${mailAccessResult.smtp.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{mailAccessResult.smtp.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>
<strong>SMTP</strong> erfolgreich ({mailAccessResult.smtp.server}:{mailAccessResult.smtp.port}, {mailAccessResult.smtp.encryption})
</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
<strong>SMTP</strong> fehlgeschlagen
{mailAccessResult.smtp.server && (
<span className="text-xs opacity-75"> ({mailAccessResult.smtp.server}:{mailAccessResult.smtp.port})</span>
)}
{mailAccessResult.smtp.error && (
<div className="mt-1 text-xs">{mailAccessResult.smtp.error}</div>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">

View File

@ -1254,6 +1254,23 @@ export const emailProviderApi = {
const res = await api.post<ApiResponse<EmailOperationResult>>('/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<ApiResponse<{
imap: { success: boolean; error?: string; server: string; port: number; encryption: string };
smtp: { success: boolean; error?: string; server: string; port: number; encryption: string };
user: string;
}>>('/email-providers/test-mail-access', body);
return res.data;
},
getDomain: async () => {
const res = await api.get<ApiResponse<{ domain: string | null }>>('/email-providers/domain');
return res.data;