Compare commits

...

5 Commits

Author SHA1 Message Date
duffyduck 92f4d7308d Fix: Provider-Domain greift sofort + Domain-Validierung
Problem: Nach dem Ändern der Provider-Domain blieb die alte Domain
(stressfrei-wechseln.de) im Adress-Hinzufügen-Dialog bestehen, weil der
Frontend-Hook useProviderSettings() einen 5-Minuten staleTime hat und
nicht invalidiert wurde.

Fix:
- In allen Provider-Mutations (create/update/delete) wird jetzt auch
  'email-provider-public-settings' invalidiert → Domain & Label greifen
  sofort in allen Komponenten

Zusätzlich Domain-Validierung eingebaut:
- Frontend: pattern am Input + Live-Fehlermeldung
  Format: name.tld (mit Subdomains erlaubt, z.B. mail.meine-firma.de)
  Input auto-lowercase + trim
- Backend: validateDomain() in createProviderConfig/updateProviderConfig
  Wirft Error mit sprechender Meldung bei ungültigem Format
- Schützt vor Versehen im UI + direkten API-Aufrufen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:51:16 +02:00
duffyduck 1290cdad10 Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider
Alle hardcoded Referenzen auf 'stressfrei-wechseln.de' und 'Stressfrei-Wechseln'
durch dynamische Werte aus der EmailProviderConfig ersetzt. Notwendig für
Multi-Mandanten-Betrieb, wenn das CRM an Dritte vermietet wird.

Schema:
- Neues Feld EmailProviderConfig.customerEmailLabel (String?)
- Wenn leer, wird Label aus Domain abgeleitet ('stressfrei-wechseln.de' → 'Stressfrei-Wechseln')

Backend:
- Neuer Endpoint GET /api/email-providers/public-settings liefert { domain, customerEmailLabel }
- Neue Service-Funktionen: getProviderPublicSettings(), deriveLabelFromDomain()
- create/updateProviderConfig erweitert um customerEmailLabel

Frontend:
- Neuer Hook useProviderSettings() mit Auto-Caching
- Neues Eingabefeld 'Bezeichnung für Kunden-E-Mails' im Provider-Modal
- Dynamische Domain-Suffix im Adress-Hinzufügen-Dialog (@<domain>)
- Tab-Label 'Stressfrei-Wechseln' im Kunden-Detail → dynamisch
- 'Stressfrei-Wechseln Adresse' in ContractForm → dynamisch
- '(Stressfrei-Wechseln)' Badge in ContractDetail → dynamisch
- 'Stressfrei-Wechseln E-Mail' im Generate-Modal → dynamisch
- Leere-Zustand-Meldungen in Tab und E-Mail-Client → dynamisch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:43:19 +02:00
duffyduck cfcdf088df Toast-Benachrichtigungen bei IMAP-Sync- und SMTP-Send-Fehlern
Bisher blieb ein fehlgeschlagener IMAP-Sync oder E-Mail-Versand still – der User
sah nur im Browser-Devtools, dass etwas schief lief. Jetzt erscheint eine rote
Toast-Benachrichtigung (8 Sekunden) mit der konkreten Fehlermeldung des Servers,
z.B. 'Sync fehlgeschlagen: IMAP-Authentifizierung fehlgeschlagen: NO [AUTHENTICATIONFAILED]'.

EmailClientTab (Synchronisieren-Button):
- toast.success bei erfolgreichem Sync
- toast.error bei Fehler + bei Backend-Response mit success=false

ComposeEmailModal (Senden):
- toast.success bei erfolgreichem Versand
- toast.error bei SMTP-Fehler mit Server-Response (zusätzlich zum Inline-Fehler)

Außerdem im imapService.testImapConnection:
- Roh-Error wird jetzt geloggt (code, response, responseStatus, authenticationFailed)
- ImapFlow-spezifische Felder werden in die Fehlermeldung übernommen, sodass
  z.B. '2 NO [AUTHENTICATIONFAILED] Authentication failed.' direkt sichtbar wird

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:16:04 +02:00
duffyduck 620bc1bcd9 Fix: IMAP/SMTP mit älteren TLS-Versionen zulassen
Der Fehler 'Client network socket disconnected before secure TLS connection
was established' tritt auf, wenn der Mailserver nur alte TLS-Versionen (1.0/1.1)
oder legacy Cipher-Suites anbietet - Node.js 20+ schließt dann den Socket, noch
bevor überhaupt ein Zertifikat gesehen wird. Das Häkchen 'Selbstsignierte
Zertifikate erlauben' greift zu spät, weil der Handshake gar nicht startet.

Fix: Wenn 'Selbstsignierte Zertifikate erlauben' aktiv ist, setzen wir gleich
auch minVersion='TLSv1' und ciphers='DEFAULT:@SECLEVEL=0'. Damit akzeptiert
Node.js auch alte Cipher-Suites und TLS-Versionen des Mailservers.

Bei aktivem 'allowSelfSignedCerts' heißt das zusammen:
- rejectUnauthorized: false (Zertifikate akzeptieren auch wenn selbstsigniert)
- minVersion: 'TLSv1' (auch alte TLS-Versionen zulassen)
- ciphers: 'DEFAULT:@SECLEVEL=0' (auch schwache Ciphers zulassen)

Refactor:
- imapService: neuer Helper buildTlsOptions() – ersetzt 8 identische
  Inline-Setups, damit die Fix-Logik zentral gepflegt wird
- smtpService: tls-Type erweitert (minVersion/ciphers), gleiche Logik

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 15:05:04 +02:00
duffyduck b76ca9fd7f 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>
2026-04-23 14:59:06 +02:00
17 changed files with 701 additions and 59 deletions
+4
View File
@@ -358,6 +358,10 @@ model EmailProviderConfig {
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
// Label für Kunden-E-Mail-Adressen in der UI (z.B. "Stressfrei-Wechseln")
// Wenn leer, wird automatisch aus der Domain abgeleitet (z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln")
customerEmailLabel String?
isActive Boolean @default(true)
isDefault Boolean @default(false) // Standard-Provider
createdAt DateTime @default(now())
@@ -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;
@@ -181,3 +337,20 @@ export async function getProviderDomain(req: Request, res: Response): Promise<vo
} as ApiResponse);
}
}
/**
* Öffentliche Provider-Einstellungen für die Frontend-UI:
* Domain + Label für Kunden-E-Mail-Adressen.
* Auch für Nicht-Admin-Mitarbeiter verfügbar, da nur UI-Labels.
*/
export async function getPublicSettings(req: Request, res: Response): Promise<void> {
try {
const settings = await emailProviderService.getProviderPublicSettings();
res.json({ success: true, data: settings } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Einstellungen',
} as ApiResponse);
}
}
@@ -15,7 +15,9 @@ 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('/public-settings', authenticate, emailProviderController.getPublicSettings);
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
router.delete('/deprovision/:localPart', authenticate, requirePermission('customers:update'), emailProviderController.deprovisionEmail);
@@ -74,11 +74,30 @@ export interface CreateProviderConfigData {
// System-E-Mail
systemEmailAddress?: string;
systemEmailPassword?: string;
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln", "Meine-Firma")
customerEmailLabel?: string;
isActive?: boolean;
isDefault?: boolean;
}
// Validiert Domain-Format (z.B. stressfrei-wechseln.de, mail.beispiel.com)
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
function validateDomain(domain: string | undefined): string {
if (!domain || !domain.trim()) {
throw new Error('Domain ist erforderlich');
}
const normalized = domain.trim().toLowerCase();
if (!DOMAIN_REGEX.test(normalized)) {
throw new Error(`Ungültige Domain: "${domain}". Format: name.tld (z.B. meine-firma.de)`);
}
return normalized;
}
export async function createProviderConfig(data: CreateProviderConfigData) {
// Domain validieren
const validatedDomain = validateDomain(data.domain);
// Falls isDefault=true, alle anderen auf false setzen
if (data.isDefault) {
await prisma.emailProviderConfig.updateMany({
@@ -100,13 +119,14 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
apiKey: data.apiKey || null,
username: data.username || null,
passwordEncrypted,
domain: data.domain,
domain: validatedDomain,
defaultForwardEmail: data.defaultForwardEmail || null,
imapEncryption: data.imapEncryption ?? 'SSL',
smtpEncryption: data.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
systemEmailAddress: data.systemEmailAddress || null,
systemEmailPasswordEncrypted,
customerEmailLabel: data.customerEmailLabel || null,
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
@@ -132,13 +152,14 @@ export async function updateProviderConfig(
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
if (data.username !== undefined) updateData.username = data.username || null;
if (data.domain !== undefined) updateData.domain = data.domain;
if (data.domain !== undefined) updateData.domain = validateDomain(data.domain);
if (data.defaultForwardEmail !== undefined)
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
if (data.customerEmailLabel !== undefined) updateData.customerEmailLabel = data.customerEmailLabel?.trim() || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
@@ -471,6 +492,39 @@ export async function getProviderDomain(): Promise<string | null> {
return config?.domain || null;
}
/**
* Label aus der Domain ableiten, z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln".
* Nimmt den Hauptteil bis zum ersten Punkt, trennt an "-" und kapitalisiert jeden Teil.
*/
export function deriveLabelFromDomain(domain: string | null | undefined): string {
if (!domain) return 'Kunden-E-Mail';
const mainPart = domain.split('.')[0] || domain;
return mainPart
.split('-')
.map((s) => (s.length === 0 ? '' : s.charAt(0).toUpperCase() + s.slice(1)))
.join('-');
}
/**
* Öffentliche Provider-Einstellungen (Domain + Label) für UI.
* Kein auth-geschütztes Geheimnis, nur damit die Frontend-Labels stimmen.
*/
export async function getProviderPublicSettings(): Promise<{
domain: string | null;
customerEmailLabel: string;
customerEmailLabelIsCustom: boolean;
}> {
const config = await getActiveProviderConfig();
const domain = config?.domain ?? null;
const customLabel = config?.customerEmailLabel?.trim();
return {
domain,
customerEmailLabel: customLabel && customLabel.length > 0 ? customLabel : deriveLabelFromDomain(domain),
customerEmailLabelIsCustom: !!(customLabel && customLabel.length > 0),
};
}
// Provider-Instanz aus übergebener Config erstellen (für Tests mit ungespeicherten Daten)
function createProviderFromFormData(data: {
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
+144 -28
View File
@@ -16,6 +16,27 @@ export interface ImapCredentials {
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
}
/**
* TLS-Optionen für IMAP-Verbindungen zusammenbauen.
* Wenn `allowSelfSignedCerts` aktiv ist, werden zusätzlich ältere TLS-Versionen
* (TLS 1.0+) und legacy Cipher-Suites erlaubt hilfreich bei älteren Mailservern,
* die sonst den Socket sofort nach Connect schließen.
*/
function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown> | undefined {
const encryption = credentials.encryption ?? 'SSL';
if (encryption === 'NONE') return undefined;
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const options: Record<string, unknown> = { rejectUnauthorized };
if (credentials.allowSelfSignedCerts) {
options.minVersion = 'TLSv1';
options.ciphers = 'DEFAULT:@SECLEVEL=0';
}
return options;
}
export interface FetchedEmail {
uid: number;
messageId: string;
@@ -106,7 +127,7 @@ export async function fetchEmails(
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
// Debug-Logging
@@ -260,7 +281,7 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
const client = new ImapFlow(clientOptions);
@@ -276,9 +297,36 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
// Ignorieren
}
// Rohes Error-Objekt loggen, damit wir ImapFlow-spezifische Felder sehen
console.error('[testImapConnection] Raw error:', error);
if (error && typeof error === 'object') {
const e = error as any;
console.error('[testImapConnection] Details:', {
code: e.code,
response: e.response,
responseStatus: e.responseStatus,
responseText: e.responseText,
authenticationFailed: e.authenticationFailed,
serverResponseCode: e.serverResponseCode,
});
}
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
const e = error as any;
// ImapFlow-spezifische Details durchreichen wenn vorhanden
if (e.authenticationFailed) {
throw new Error(
`IMAP-Authentifizierung fehlgeschlagen${e.response ? `: ${e.response}` : ''}`,
);
}
if (e.response || e.responseText) {
throw new Error(
`IMAP ${e.responseStatus || 'Fehler'}: ${e.response || e.responseText}`,
);
}
if (msg.includes('authentication') || msg.includes('login')) {
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
@@ -325,7 +373,7 @@ export async function getHighestUid(
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
const client = new ImapFlow(clientOptions);
@@ -360,6 +408,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,45 +457,78 @@ export async function fetchAttachment(
pass: credentials.password,
},
logger: false,
// Timeouts gegen hängende Verbindungen
socketTimeout: 30000,
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
const client = new ImapFlow(clientOptions);
console.log(`[fetchAttachment] Host: ${credentials.host}:${credentials.port} | User: ${credentials.user} | Folder: ${folder} | UID: ${uid} | File: ${attachmentFilename} | AllowSelfSigned: ${credentials.allowSelfSignedCerts}`);
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();
@@ -459,7 +575,7 @@ export async function appendToSent(
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
console.log(`[IMAP] Appending email to ${sentFolder} folder...`);
@@ -545,7 +661,7 @@ export async function fetchAttachmentList(
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
const client = new ImapFlow(clientOptions);
@@ -637,7 +753,7 @@ export async function moveToTrash(
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
const client = new ImapFlow(clientOptions);
@@ -710,7 +826,7 @@ export async function restoreFromTrash(
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
const client = new ImapFlow(clientOptions);
@@ -781,7 +897,7 @@ export async function permanentDelete(
};
if (encryption !== 'NONE') {
clientOptions.tls = { rejectUnauthorized };
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
}
const client = new ImapFlow(clientOptions);
+7 -2
View File
@@ -69,7 +69,7 @@ export async function sendEmail(
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean };
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
ignoreTLS?: boolean;
requireTLS?: boolean;
connectionTimeout: number;
@@ -91,6 +91,11 @@ export async function sendEmail(
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
transportOptions.tls = { rejectUnauthorized };
if (credentials.allowSelfSignedCerts) {
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
transportOptions.tls.minVersion = 'TLSv1';
transportOptions.tls.ciphers = 'DEFAULT:@SECLEVEL=0';
}
} else {
// Keine Verschlüsselung: STARTTLS ignorieren
transportOptions.ignoreTLS = true;
@@ -273,7 +278,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean };
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
ignoreTLS?: boolean;
connectionTimeout: number;
greetingTimeout: number;
+9
View File
@@ -14,6 +14,15 @@
## ✅ Erledigt
- [x] **Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider**
- Neues Feld `customerEmailLabel` am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma")
- Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" → "Stressfrei-Wechseln")
- Neuer Frontend-Hook `useProviderSettings()` liefert Domain + Label
- Alle hardcoded "Stressfrei-Wechseln" und `@stressfrei-wechseln.de` Strings durch dynamische Werte ersetzt
(CustomerDetail, ContractForm, ContractDetail, EmailClientTab, Settings)
- Modal-Eingabefeld "Bezeichnung für Kunden-E-Mails" in Provider-Einstellungen
- Notwendig für Multi-Mandanten-Betrieb wenn das CRM an Dritte vermietet wird
- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
- Enthält: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
- Enthält NICHT: Kundendaten, Verträge, Dokumente, Emails, Einstellungen (dafür gibt es den Datenbank-Backup)
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, FileText } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
@@ -150,12 +151,24 @@ export default function ComposeEmailModal({
attachments: attachments.length > 0 ? attachments : undefined,
contractId,
}),
onSuccess: () => {
onSuccess: (result) => {
// Backend kann success=false zurückgeben auch bei HTTP 200
if (result && (result as any).success === false) {
const msg = (result as any).error || 'E-Mail-Versand fehlgeschlagen';
setError(msg);
toast.error(`SMTP-Fehler: ${msg}`, { duration: 8000 });
return;
}
toast.success('E-Mail versendet');
onSuccess?.();
handleClose();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Senden');
onError: (err: any) => {
const msg =
err?.response?.data?.error ||
(err instanceof Error ? err.message : 'Fehler beim Senden');
setError(msg);
toast.error(`SMTP-Fehler: ${msg}`, { duration: 8000 });
},
});
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import Button from '../ui/Button';
import EmailList from './EmailList';
import EmailDetail from './EmailDetail';
@@ -17,6 +19,7 @@ interface EmailClientTabProps {
}
export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const { customerEmailLabel } = useProviderSettings();
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
@@ -95,7 +98,14 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
// Synchronisation
const syncMutation = useMutation({
mutationFn: (accountId: number) => stressfreiEmailApi.syncEmails(accountId),
onSuccess: () => {
onSuccess: (result) => {
// Backend liefert success=false bei IMAP-Fehler, aber ohne HTTP-Error
if (result && (result as any).success === false) {
const err = (result as any).error || 'IMAP-Synchronisation fehlgeschlagen';
toast.error(`Sync fehlgeschlagen: ${err}`, { duration: 8000 });
return;
}
toast.success('E-Mails synchronisiert');
// E-Mail-Listen neu laden
queryClient.invalidateQueries({ queryKey: ['emails'] });
// Ordner-Anzahlen aktualisieren
@@ -103,6 +113,10 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
// Mailbox-Accounts aktualisieren
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
},
onError: (error: any) => {
const msg = error?.response?.data?.error || error?.message || 'Unbekannter Fehler';
toast.error(`Sync fehlgeschlagen: ${msg}`, { duration: 8000 });
},
});
const handleSync = () => {
@@ -138,7 +152,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
Keine E-Mail-Konten vorhanden
</h3>
<p className="text-sm text-center max-w-md">
Erstellen Sie eine Stressfrei-Wechseln E-Mail-Adresse mit aktivierter Mailbox,
Erstellen Sie eine {customerEmailLabel} E-Mail-Adresse mit aktivierter Mailbox,
um E-Mails hier empfangen und versenden zu können.
</p>
</div>
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { emailProviderApi } from '../services/api';
export interface ProviderSettings {
domain: string | null;
customerEmailLabel: string; // z.B. "Stressfrei-Wechseln" (aus Config oder aus Domain abgeleitet)
customerEmailLabelIsCustom: boolean;
}
/**
* Holt die öffentlichen Provider-Einstellungen (Domain + Label für Kunden-E-Mail-Adressen).
* Mit Default-Fallback bei Ladefehler UI-Labels werden dann generisch angezeigt.
*/
export function useProviderSettings(): ProviderSettings {
const { data } = useQuery({
queryKey: ['email-provider-public-settings'],
queryFn: () => emailProviderApi.getPublicSettings(),
staleTime: 5 * 60_000,
});
return {
domain: data?.data?.domain ?? null,
customerEmailLabel: data?.data?.customerEmailLabel || 'Kunden-E-Mail',
customerEmailLabelIsCustom: data?.data?.customerEmailLabelIsCustom ?? false,
};
}
+1 -1
View File
@@ -156,7 +156,7 @@ export default function Settings() {
Email-Provisionierung
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die automatische E-Mail-Erstellung für Stressfrei-Wechseln Adressen.</p>
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die automatische E-Mail-Erstellung für Kunden-E-Mail-Adressen.</p>
</div>
</div>
</Link>
@@ -17,6 +17,7 @@ import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, Exter
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
const typeLabels: Record<ContractType, string> = {
@@ -1444,6 +1445,7 @@ export default function ContractDetail() {
const currentPath = `/contracts/${id}`;
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
const contractId = parseInt(id!);
const { customerEmailLabel } = useProviderSettings();
const [showPassword, setShowPassword] = useState(false);
const [decryptedPassword, setDecryptedPassword] = useState<string | null>(null);
@@ -2310,7 +2312,7 @@ export default function ContractDetail() {
<dt className="text-sm text-gray-500">
Benutzername
{c.stressfreiEmail && (
<span className="ml-2 text-xs text-blue-600">(Stressfrei-Wechseln)</span>
<span className="ml-2 text-xs text-blue-600">({customerEmailLabel})</span>
)}
</dt>
<dd className="font-mono flex items-center gap-1">
@@ -3363,6 +3365,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
const [manualValues, setManualValues] = useState<Record<string, string>>({});
const [generating] = useState(false);
const { customerEmailLabel } = useProviderSettings();
const { data: inputsData, isLoading } = useQuery({
queryKey: ['pdf-inputs', templateId, contractId],
@@ -3390,7 +3393,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
<div className="space-y-4">
{inputs?.needsStressfreiEmail && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Stressfrei-Wechseln E-Mail</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{customerEmailLabel} E-Mail</label>
<select
value={stressfreiEmailId}
onChange={(e) => setStressfreiEmailId(e.target.value)}
@@ -10,6 +10,7 @@ import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import type { ContractType } from '../../types';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
// Contract types are now loaded dynamically from the database
@@ -67,6 +68,7 @@ export default function ContractForm() {
const queryClient = useQueryClient();
const isEdit = !!id;
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
const { customerEmailLabel } = useProviderSettings();
const preselectedCustomerId = searchParams.get('customerId');
@@ -914,7 +916,7 @@ export default function ContractForm() {
}}
className="text-blue-600"
/>
<span className="text-sm">Stressfrei-Wechseln Adresse</span>
<span className="text-sm">{customerEmailLabel} Adresse</span>
</label>
{usernameType === 'stressfrei' && (
<Select
@@ -929,7 +931,7 @@ export default function ContractForm() {
)}
{usernameType === 'stressfrei' && stressfreiEmails.length === 0 && (
<p className="text-xs text-amber-600">
Keine Stressfrei-Wechseln Adressen für diesen Kunden vorhanden. Bitte zuerst beim Kunden anlegen.
Keine {customerEmailLabel} Adressen für diesen Kunden vorhanden. Bitte zuerst beim Kunden anlegen.
</p>
)}
</div>
@@ -18,6 +18,7 @@ import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
import { formatDate } from '../../utils/dateFormat';
import { getContractTypeInfo } from '../../utils/contractInfo';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
@@ -31,6 +32,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
const customerId = portalCustomerId || parseInt(id!);
const defaultTab = searchParams.get('tab') || 'addresses';
const [activeTab, setActiveTab] = useState(defaultTab);
const { customerEmailLabel } = useProviderSettings();
// Tab-Wechsel in URL synchronisieren (für Browser-History)
const handleTabChange = (tabId: string) => {
@@ -148,7 +150,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
},
...(!isCustomerPortal ? [{
id: 'stressfrei',
label: 'Stressfrei-Wechseln',
label: customerEmailLabel,
content: (
<StressfreiEmailsTab
customerId={customerId}
@@ -2928,9 +2930,7 @@ function MeterReadingModal({
);
}
// ==================== STRESSFREI-WECHSELN E-MAIL TAB ====================
const STRESSFREI_DOMAIN = '@stressfrei-wechseln.de';
// ==================== KUNDEN-E-MAIL TAB ====================
function StressfreiEmailsTab({
customerId,
@@ -2950,6 +2950,7 @@ function StressfreiEmailsTab({
onEdit: (email: StressfreiEmail) => void;
}) {
const queryClient = useQueryClient();
const { customerEmailLabel } = useProviderSettings();
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
@@ -3067,7 +3068,7 @@ function StressfreiEmailsTab({
))}
</div>
) : (
<p className="text-gray-500">Keine Stressfrei-Wechseln Adressen vorhanden.</p>
<p className="text-gray-500">Keine {customerEmailLabel} Adressen vorhanden.</p>
)}
</div>
);
@@ -3240,6 +3241,10 @@ function StressfreiEmailModal({
const [provisionError, setProvisionError] = useState<string | null>(null);
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
const [isProvisioning, setIsProvisioning] = useState(false);
// Domain dynamisch vom Provider (mit Fallback)
const { domain: providerDomain } = useProviderSettings();
const domainSuffix = `@${providerDomain || 'stressfrei-wechseln.de'}`;
const [isEnablingMailbox, setIsEnablingMailbox] = useState(false);
const [mailboxEnabled, setMailboxEnabled] = useState(false);
const [showCredentials, setShowCredentials] = useState(false);
@@ -3446,7 +3451,7 @@ function StressfreiEmailModal({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setProvisionError(null);
const fullEmail = localPart + STRESSFREI_DOMAIN;
const fullEmail = localPart + domainSuffix;
if (isEditing) {
updateMutation.mutate({
@@ -3482,11 +3487,11 @@ function StressfreiEmailModal({
className="block w-full px-3 py-2 border border-gray-300 rounded-l-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 bg-gray-100 text-gray-600 rounded-r-lg text-sm">
{STRESSFREI_DOMAIN}
{domainSuffix}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
Vollständige Adresse: <span className="font-mono">{localPart || '...'}{STRESSFREI_DOMAIN}</span>
Vollständige Adresse: <span className="font-mono">{localPart || '...'}{domainSuffix}</span>
</p>
</div>
+182 -8
View File
@@ -39,6 +39,8 @@ interface ProviderFormData {
// System-E-Mail
systemEmailAddress: string;
systemEmailPassword: string;
// UI-Label für Kunden-E-Mail-Adressen
customerEmailLabel: string;
isActive: boolean;
isDefault: boolean;
}
@@ -57,6 +59,7 @@ const emptyForm: ProviderFormData = {
allowSelfSignedCerts: false,
systemEmailAddress: '',
systemEmailPassword: '',
customerEmailLabel: '',
isActive: true,
isDefault: false,
};
@@ -80,6 +83,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'],
@@ -91,6 +101,7 @@ export default function EmailProviders() {
emailProviderApi.createConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
closeModal();
},
});
@@ -100,6 +111,7 @@ export default function EmailProviders() {
emailProviderApi.updateConfig(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
closeModal();
},
});
@@ -108,6 +120,7 @@ export default function EmailProviders() {
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
},
});
@@ -136,6 +149,7 @@ export default function EmailProviders() {
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
systemEmailAddress: config.systemEmailAddress || '',
systemEmailPassword: '', // Passwort wird nicht geladen
customerEmailLabel: config.customerEmailLabel || '',
isActive: config.isActive,
isDefault: config.isDefault,
});
@@ -151,6 +165,7 @@ export default function EmailProviders() {
setFormData(emptyForm);
setShowPassword(false);
setModalTestResult(null);
setMailAccessResult(null);
};
// Test für einen gespeicherten Provider in der Liste
@@ -216,6 +231,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();
@@ -231,6 +297,7 @@ export default function EmailProviders() {
smtpEncryption: formData.smtpEncryption,
allowSelfSignedCerts: formData.allowSelfSignedCerts,
systemEmailAddress: formData.systemEmailAddress,
customerEmailLabel: formData.customerEmailLabel?.trim() || null,
isActive: formData.isActive,
isDefault: formData.isDefault,
};
@@ -277,9 +344,9 @@ export default function EmailProviders() {
<Card className="mb-6">
<p className="text-gray-600 mb-4">
Hier konfigurieren Sie die automatische Erstellung von Stressfrei-Wechseln E-Mail-Adressen.
Wenn beim Anlegen einer Stressfrei-Adresse die Option "Bei Provider anlegen" aktiviert ist,
wird die E-Mail-Weiterleitung automatisch erstellt.
Hier konfigurieren Sie die automatische Erstellung von Kunden-E-Mail-Adressen auf Ihrer
eigenen Domain. Wenn beim Anlegen einer Adresse die Option "Bei Provider anlegen"
aktiviert ist, wird die E-Mail-Weiterleitung automatisch erstellt.
</p>
<Button onClick={openCreateModal}>
<Plus className="w-4 h-4 mr-2" />
@@ -483,13 +550,37 @@ export default function EmailProviders() {
</div>
</div>
<div>
<Input
label="Domain *"
value={formData.domain}
onChange={(e) =>
setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })
}
placeholder="stressfrei-wechseln.de"
required
pattern="^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)+$"
title="Gültige Domain erforderlich, z.B. meine-firma.de oder mail.beispiel.com"
/>
{formData.domain &&
!/^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)+$/.test(
formData.domain,
) && (
<p className="text-xs text-red-600 mt-1">
Keine gültige Domain Format: name.tld (z.B. meine-firma.de)
</p>
)}
</div>
<Input
label="Domain *"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
placeholder="stressfrei-wechseln.de"
required
label="Bezeichnung für Kunden-E-Mails (UI-Label)"
value={formData.customerEmailLabel}
onChange={(e) => setFormData({ ...formData, customerEmailLabel: e.target.value })}
placeholder={`wird aus Domain abgeleitet, z.B. "${formData.domain ? formData.domain.split('.')[0].split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('-') : 'Stressfrei-Wechseln'}"`}
/>
<p className="text-xs text-gray-500 -mt-2">
Wird überall dort angezeigt, wo es bisher "Stressfrei-Wechseln" hieß (z.B. Tab-Name, Adress-Listen). Wenn leer, wird der Name aus der Domain abgeleitet.
</p>
<Input
label="Standard-Weiterleitungsadresse"
@@ -659,6 +750,89 @@ 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}
{mailAccessResult.imap.encryption ? `, ${mailAccessResult.imap.encryption}` : ''})
</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}
{mailAccessResult.smtp.encryption ? `, ${mailAccessResult.smtp.encryption}` : ''})
</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">
+27
View File
@@ -1200,6 +1200,8 @@ export interface EmailProviderConfig {
// System-E-Mail für automatisierten Versand
systemEmailAddress?: string;
systemEmailPasswordEncrypted?: string;
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln")
customerEmailLabel?: string | null;
isActive: boolean;
isDefault: boolean;
createdAt: string;
@@ -1254,10 +1256,35 @@ 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;
},
getPublicSettings: async () => {
const res = await api.get<ApiResponse<{
domain: string | null;
customerEmailLabel: string;
customerEmailLabelIsCustom: boolean;
}>>('/email-providers/public-settings');
return res.data;
},
checkEmailExists: async (localPart: string) => {
const res = await api.get<ApiResponse<{ exists: boolean; email?: string }>>(`/email-providers/check/${localPart}`);
return res.data;