Compare commits
5 Commits
4c65353917
...
92f4d7308d
| Author | SHA1 | Date | |
|---|---|---|---|
| 92f4d7308d | |||
| 1290cdad10 | |||
| cfcdf088df | |||
| 620bc1bcd9 | |||
| b76ca9fd7f |
@@ -358,6 +358,10 @@ model EmailProviderConfig {
|
|||||||
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
|
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
|
||||||
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
|
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)
|
isActive Boolean @default(true)
|
||||||
isDefault Boolean @default(false) // Standard-Provider
|
isDefault Boolean @default(false) // Standard-Provider
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -508,9 +508,24 @@ export async function downloadAttachment(req: Request, res: Response): Promise<v
|
|||||||
res.send(attachment.content);
|
res.send(attachment.content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('downloadAttachment error:', 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({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Fehler beim Herunterladen des Anhangs',
|
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { Request, Response } from 'express';
|
|||||||
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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 ====================
|
// ==================== 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> {
|
export async function checkEmailExists(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { localPart } = req.params;
|
const { localPart } = req.params;
|
||||||
@@ -181,3 +337,20 @@ export async function getProviderDomain(req: Request, res: Response): Promise<vo
|
|||||||
} as ApiResponse);
|
} 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
|
// Email Operations
|
||||||
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
|
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('/domain', authenticate, emailProviderController.getProviderDomain);
|
||||||
|
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
||||||
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
||||||
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
|
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
|
||||||
router.delete('/deprovision/:localPart', authenticate, requirePermission('customers:update'), emailProviderController.deprovisionEmail);
|
router.delete('/deprovision/:localPart', authenticate, requirePermission('customers:update'), emailProviderController.deprovisionEmail);
|
||||||
|
|||||||
@@ -74,11 +74,30 @@ export interface CreateProviderConfigData {
|
|||||||
// System-E-Mail
|
// System-E-Mail
|
||||||
systemEmailAddress?: string;
|
systemEmailAddress?: string;
|
||||||
systemEmailPassword?: string;
|
systemEmailPassword?: string;
|
||||||
|
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln", "Meine-Firma")
|
||||||
|
customerEmailLabel?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
isDefault?: 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) {
|
export async function createProviderConfig(data: CreateProviderConfigData) {
|
||||||
|
// Domain validieren
|
||||||
|
const validatedDomain = validateDomain(data.domain);
|
||||||
|
|
||||||
// Falls isDefault=true, alle anderen auf false setzen
|
// Falls isDefault=true, alle anderen auf false setzen
|
||||||
if (data.isDefault) {
|
if (data.isDefault) {
|
||||||
await prisma.emailProviderConfig.updateMany({
|
await prisma.emailProviderConfig.updateMany({
|
||||||
@@ -100,13 +119,14 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
|
|||||||
apiKey: data.apiKey || null,
|
apiKey: data.apiKey || null,
|
||||||
username: data.username || null,
|
username: data.username || null,
|
||||||
passwordEncrypted,
|
passwordEncrypted,
|
||||||
domain: data.domain,
|
domain: validatedDomain,
|
||||||
defaultForwardEmail: data.defaultForwardEmail || null,
|
defaultForwardEmail: data.defaultForwardEmail || null,
|
||||||
imapEncryption: data.imapEncryption ?? 'SSL',
|
imapEncryption: data.imapEncryption ?? 'SSL',
|
||||||
smtpEncryption: data.smtpEncryption ?? 'SSL',
|
smtpEncryption: data.smtpEncryption ?? 'SSL',
|
||||||
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
||||||
systemEmailAddress: data.systemEmailAddress || null,
|
systemEmailAddress: data.systemEmailAddress || null,
|
||||||
systemEmailPasswordEncrypted,
|
systemEmailPasswordEncrypted,
|
||||||
|
customerEmailLabel: data.customerEmailLabel || null,
|
||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
isDefault: data.isDefault ?? false,
|
isDefault: data.isDefault ?? false,
|
||||||
},
|
},
|
||||||
@@ -132,13 +152,14 @@ export async function updateProviderConfig(
|
|||||||
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
|
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
|
||||||
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
|
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
|
||||||
if (data.username !== undefined) updateData.username = data.username || 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)
|
if (data.defaultForwardEmail !== undefined)
|
||||||
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
|
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
|
||||||
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
|
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
|
||||||
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
|
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
|
||||||
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
|
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
|
||||||
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
|
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.isActive !== undefined) updateData.isActive = data.isActive;
|
||||||
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
|
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
|
||||||
|
|
||||||
@@ -471,6 +492,39 @@ export async function getProviderDomain(): Promise<string | null> {
|
|||||||
return config?.domain || 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)
|
// Provider-Instanz aus übergebener Config erstellen (für Tests mit ungespeicherten Daten)
|
||||||
function createProviderFromFormData(data: {
|
function createProviderFromFormData(data: {
|
||||||
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ export interface ImapCredentials {
|
|||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
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 {
|
export interface FetchedEmail {
|
||||||
uid: number;
|
uid: number;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -106,7 +127,7 @@ export async function fetchEmails(
|
|||||||
|
|
||||||
// TLS-Optionen nur wenn nicht NONE
|
// TLS-Optionen nur wenn nicht NONE
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug-Logging
|
// Debug-Logging
|
||||||
@@ -260,7 +281,7 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -276,9 +297,36 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
|
|||||||
// Ignorieren
|
// 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) {
|
if (error instanceof Error) {
|
||||||
const msg = error.message.toLowerCase();
|
const msg = error.message.toLowerCase();
|
||||||
const errorCode = (error as NodeJS.ErrnoException).code?.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')) {
|
if (msg.includes('authentication') || msg.includes('login')) {
|
||||||
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
|
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
|
||||||
@@ -325,7 +373,7 @@ export async function getHighestUid(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -360,6 +408,41 @@ export async function fetchAttachment(
|
|||||||
uid: number,
|
uid: number,
|
||||||
attachmentFilename: string,
|
attachmentFilename: string,
|
||||||
folder: string = 'INBOX'
|
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> {
|
): Promise<EmailAttachmentData | null> {
|
||||||
// Verschlüsselungs-Einstellungen basierend auf Modus
|
// Verschlüsselungs-Einstellungen basierend auf Modus
|
||||||
const encryption = credentials.encryption ?? 'SSL';
|
const encryption = credentials.encryption ?? 'SSL';
|
||||||
@@ -374,45 +457,78 @@ export async function fetchAttachment(
|
|||||||
pass: credentials.password,
|
pass: credentials.password,
|
||||||
},
|
},
|
||||||
logger: false,
|
logger: false,
|
||||||
|
// Timeouts gegen hängende Verbindungen
|
||||||
|
socketTimeout: 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
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 {
|
try {
|
||||||
await client.connect();
|
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
|
// E-Mail per UID abrufen
|
||||||
let attachment: EmailAttachmentData | null = null;
|
let attachment: EmailAttachmentData | null = null;
|
||||||
|
let foundMessage = false;
|
||||||
|
|
||||||
// Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird
|
// Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird
|
||||||
for await (const message of client.fetch(uid.toString(), {
|
try {
|
||||||
source: true,
|
for await (const message of client.fetch(uid.toString(), {
|
||||||
}, { uid: true })) {
|
source: true,
|
||||||
if (!message.source) continue;
|
}, { uid: true })) {
|
||||||
|
foundMessage = true;
|
||||||
|
if (!message.source) continue;
|
||||||
|
|
||||||
// E-Mail parsen
|
// E-Mail parsen
|
||||||
const parsed = await simpleParser(message.source);
|
const parsed = await simpleParser(message.source);
|
||||||
|
|
||||||
// Anhang suchen
|
// Anhang suchen
|
||||||
if (parsed.attachments) {
|
if (parsed.attachments) {
|
||||||
for (const att of parsed.attachments) {
|
for (const att of parsed.attachments) {
|
||||||
const filename = att.filename || 'unnamed';
|
const filename = att.filename || 'unnamed';
|
||||||
if (filename === attachmentFilename) {
|
if (filename === attachmentFilename) {
|
||||||
attachment = {
|
attachment = {
|
||||||
filename,
|
filename,
|
||||||
content: att.content,
|
content: att.content,
|
||||||
contentType: att.contentType || 'application/octet-stream',
|
contentType: att.contentType || 'application/octet-stream',
|
||||||
size: att.size,
|
size: att.size,
|
||||||
};
|
};
|
||||||
break;
|
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();
|
await client.logout();
|
||||||
@@ -459,7 +575,7 @@ export async function appendToSent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
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...`);
|
console.log(`[IMAP] Appending email to ${sentFolder} folder...`);
|
||||||
@@ -545,7 +661,7 @@ export async function fetchAttachmentList(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -637,7 +753,7 @@ export async function moveToTrash(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -710,7 +826,7 @@ export async function restoreFromTrash(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -781,7 +897,7 @@ export async function permanentDelete(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
clientOptions.tls = { rejectUnauthorized };
|
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export async function sendEmail(
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
requireTLS?: boolean;
|
requireTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
@@ -91,6 +91,11 @@ export async function sendEmail(
|
|||||||
// TLS-Optionen nur wenn nicht NONE
|
// TLS-Optionen nur wenn nicht NONE
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
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 {
|
} else {
|
||||||
// Keine Verschlüsselung: STARTTLS ignorieren
|
// Keine Verschlüsselung: STARTTLS ignorieren
|
||||||
transportOptions.ignoreTLS = true;
|
transportOptions.ignoreTLS = true;
|
||||||
@@ -273,7 +278,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean };
|
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
greetingTimeout: number;
|
greetingTimeout: number;
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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**
|
- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
|
||||||
- Enthält: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
|
- 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)
|
- 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 { useState, useRef, useEffect } from 'react';
|
||||||
import { Send, Paperclip, X, FileText } from 'lucide-react';
|
import { Send, Paperclip, X, FileText } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
||||||
@@ -150,12 +151,24 @@ export default function ComposeEmailModal({
|
|||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
contractId,
|
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?.();
|
onSuccess?.();
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err: any) => {
|
||||||
setError(err instanceof Error ? err.message : 'Fehler beim Senden');
|
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 { useState, useEffect } from 'react';
|
||||||
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
|
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
|
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import EmailList from './EmailList';
|
import EmailList from './EmailList';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
@@ -17,6 +19,7 @@ interface EmailClientTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||||
|
const { customerEmailLabel } = useProviderSettings();
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
|
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
|
||||||
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
|
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
|
||||||
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
|
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
|
||||||
@@ -95,7 +98,14 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
// Synchronisation
|
// Synchronisation
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: (accountId: number) => stressfreiEmailApi.syncEmails(accountId),
|
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
|
// E-Mail-Listen neu laden
|
||||||
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
||||||
// Ordner-Anzahlen aktualisieren
|
// Ordner-Anzahlen aktualisieren
|
||||||
@@ -103,6 +113,10 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
// Mailbox-Accounts aktualisieren
|
// Mailbox-Accounts aktualisieren
|
||||||
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
|
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 = () => {
|
const handleSync = () => {
|
||||||
@@ -138,7 +152,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
Keine E-Mail-Konten vorhanden
|
Keine E-Mail-Konten vorhanden
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-center max-w-md">
|
<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.
|
um E-Mails hier empfangen und versenden zu können.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ export default function Settings() {
|
|||||||
Email-Provisionierung
|
Email-Provisionierung
|
||||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</h3>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, Exter
|
|||||||
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
||||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
|
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
|
||||||
|
|
||||||
const typeLabels: Record<ContractType, string> = {
|
const typeLabels: Record<ContractType, string> = {
|
||||||
@@ -1444,6 +1445,7 @@ export default function ContractDetail() {
|
|||||||
const currentPath = `/contracts/${id}`;
|
const currentPath = `/contracts/${id}`;
|
||||||
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
|
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
|
||||||
const contractId = parseInt(id!);
|
const contractId = parseInt(id!);
|
||||||
|
const { customerEmailLabel } = useProviderSettings();
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [decryptedPassword, setDecryptedPassword] = useState<string | null>(null);
|
const [decryptedPassword, setDecryptedPassword] = useState<string | null>(null);
|
||||||
@@ -2310,7 +2312,7 @@ export default function ContractDetail() {
|
|||||||
<dt className="text-sm text-gray-500">
|
<dt className="text-sm text-gray-500">
|
||||||
Benutzername
|
Benutzername
|
||||||
{c.stressfreiEmail && (
|
{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>
|
</dt>
|
||||||
<dd className="font-mono flex items-center gap-1">
|
<dd className="font-mono flex items-center gap-1">
|
||||||
@@ -3363,6 +3365,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
|
|||||||
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
|
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
|
||||||
const [manualValues, setManualValues] = useState<Record<string, string>>({});
|
const [manualValues, setManualValues] = useState<Record<string, string>>({});
|
||||||
const [generating] = useState(false);
|
const [generating] = useState(false);
|
||||||
|
const { customerEmailLabel } = useProviderSettings();
|
||||||
|
|
||||||
const { data: inputsData, isLoading } = useQuery({
|
const { data: inputsData, isLoading } = useQuery({
|
||||||
queryKey: ['pdf-inputs', templateId, contractId],
|
queryKey: ['pdf-inputs', templateId, contractId],
|
||||||
@@ -3390,7 +3393,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{inputs?.needsStressfreiEmail && (
|
{inputs?.needsStressfreiEmail && (
|
||||||
<div>
|
<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
|
<select
|
||||||
value={stressfreiEmailId}
|
value={stressfreiEmailId}
|
||||||
onChange={(e) => setStressfreiEmailId(e.target.value)}
|
onChange={(e) => setStressfreiEmailId(e.target.value)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Input from '../../components/ui/Input';
|
|||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
|
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
// Contract types are now loaded dynamically from the database
|
// Contract types are now loaded dynamically from the database
|
||||||
@@ -67,6 +68,7 @@ export default function ContractForm() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isEdit = !!id;
|
const isEdit = !!id;
|
||||||
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
|
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
|
||||||
|
const { customerEmailLabel } = useProviderSettings();
|
||||||
|
|
||||||
const preselectedCustomerId = searchParams.get('customerId');
|
const preselectedCustomerId = searchParams.get('customerId');
|
||||||
|
|
||||||
@@ -914,7 +916,7 @@ export default function ContractForm() {
|
|||||||
}}
|
}}
|
||||||
className="text-blue-600"
|
className="text-blue-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Stressfrei-Wechseln Adresse</span>
|
<span className="text-sm">{customerEmailLabel} Adresse</span>
|
||||||
</label>
|
</label>
|
||||||
{usernameType === 'stressfrei' && (
|
{usernameType === 'stressfrei' && (
|
||||||
<Select
|
<Select
|
||||||
@@ -929,7 +931,7 @@ export default function ContractForm() {
|
|||||||
)}
|
)}
|
||||||
{usernameType === 'stressfrei' && stressfreiEmails.length === 0 && (
|
{usernameType === 'stressfrei' && stressfreiEmails.length === 0 && (
|
||||||
<p className="text-xs text-amber-600">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
|||||||
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { getContractTypeInfo } from '../../utils/contractInfo';
|
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';
|
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
|
||||||
|
|
||||||
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
|
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
|
||||||
@@ -31,6 +32,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
const customerId = portalCustomerId || parseInt(id!);
|
const customerId = portalCustomerId || parseInt(id!);
|
||||||
const defaultTab = searchParams.get('tab') || 'addresses';
|
const defaultTab = searchParams.get('tab') || 'addresses';
|
||||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||||
|
const { customerEmailLabel } = useProviderSettings();
|
||||||
|
|
||||||
// Tab-Wechsel in URL synchronisieren (für Browser-History)
|
// Tab-Wechsel in URL synchronisieren (für Browser-History)
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
@@ -148,7 +150,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
},
|
},
|
||||||
...(!isCustomerPortal ? [{
|
...(!isCustomerPortal ? [{
|
||||||
id: 'stressfrei',
|
id: 'stressfrei',
|
||||||
label: 'Stressfrei-Wechseln',
|
label: customerEmailLabel,
|
||||||
content: (
|
content: (
|
||||||
<StressfreiEmailsTab
|
<StressfreiEmailsTab
|
||||||
customerId={customerId}
|
customerId={customerId}
|
||||||
@@ -2928,9 +2930,7 @@ function MeterReadingModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== STRESSFREI-WECHSELN E-MAIL TAB ====================
|
// ==================== KUNDEN-E-MAIL TAB ====================
|
||||||
|
|
||||||
const STRESSFREI_DOMAIN = '@stressfrei-wechseln.de';
|
|
||||||
|
|
||||||
function StressfreiEmailsTab({
|
function StressfreiEmailsTab({
|
||||||
customerId,
|
customerId,
|
||||||
@@ -2950,6 +2950,7 @@ function StressfreiEmailsTab({
|
|||||||
onEdit: (email: StressfreiEmail) => void;
|
onEdit: (email: StressfreiEmail) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { customerEmailLabel } = useProviderSettings();
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
|
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
|
||||||
@@ -3067,7 +3068,7 @@ function StressfreiEmailsTab({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">Keine Stressfrei-Wechseln Adressen vorhanden.</p>
|
<p className="text-gray-500">Keine {customerEmailLabel} Adressen vorhanden.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3240,6 +3241,10 @@ function StressfreiEmailModal({
|
|||||||
const [provisionError, setProvisionError] = useState<string | null>(null);
|
const [provisionError, setProvisionError] = useState<string | null>(null);
|
||||||
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
|
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
|
||||||
const [isProvisioning, setIsProvisioning] = useState(false);
|
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 [isEnablingMailbox, setIsEnablingMailbox] = useState(false);
|
||||||
const [mailboxEnabled, setMailboxEnabled] = useState(false);
|
const [mailboxEnabled, setMailboxEnabled] = useState(false);
|
||||||
const [showCredentials, setShowCredentials] = useState(false);
|
const [showCredentials, setShowCredentials] = useState(false);
|
||||||
@@ -3446,7 +3451,7 @@ function StressfreiEmailModal({
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setProvisionError(null);
|
setProvisionError(null);
|
||||||
const fullEmail = localPart + STRESSFREI_DOMAIN;
|
const fullEmail = localPart + domainSuffix;
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
updateMutation.mutate({
|
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"
|
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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ interface ProviderFormData {
|
|||||||
// System-E-Mail
|
// System-E-Mail
|
||||||
systemEmailAddress: string;
|
systemEmailAddress: string;
|
||||||
systemEmailPassword: string;
|
systemEmailPassword: string;
|
||||||
|
// UI-Label für Kunden-E-Mail-Adressen
|
||||||
|
customerEmailLabel: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,7 @@ const emptyForm: ProviderFormData = {
|
|||||||
allowSelfSignedCerts: false,
|
allowSelfSignedCerts: false,
|
||||||
systemEmailAddress: '',
|
systemEmailAddress: '',
|
||||||
systemEmailPassword: '',
|
systemEmailPassword: '',
|
||||||
|
customerEmailLabel: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
};
|
};
|
||||||
@@ -80,6 +83,13 @@ export default function EmailProviders() {
|
|||||||
// Test-Status pro Provider in der Liste
|
// Test-Status pro Provider in der Liste
|
||||||
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
|
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
|
||||||
const [testingProviderId, setTestingProviderId] = useState<number | null>(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({
|
const { data: configsData, isLoading } = useQuery({
|
||||||
queryKey: ['email-provider-configs'],
|
queryKey: ['email-provider-configs'],
|
||||||
@@ -91,6 +101,7 @@ export default function EmailProviders() {
|
|||||||
emailProviderApi.createConfig(data),
|
emailProviderApi.createConfig(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
|
||||||
closeModal();
|
closeModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -100,6 +111,7 @@ export default function EmailProviders() {
|
|||||||
emailProviderApi.updateConfig(id, data),
|
emailProviderApi.updateConfig(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
|
||||||
closeModal();
|
closeModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -108,6 +120,7 @@ export default function EmailProviders() {
|
|||||||
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
|
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
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,
|
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
|
||||||
systemEmailAddress: config.systemEmailAddress || '',
|
systemEmailAddress: config.systemEmailAddress || '',
|
||||||
systemEmailPassword: '', // Passwort wird nicht geladen
|
systemEmailPassword: '', // Passwort wird nicht geladen
|
||||||
|
customerEmailLabel: config.customerEmailLabel || '',
|
||||||
isActive: config.isActive,
|
isActive: config.isActive,
|
||||||
isDefault: config.isDefault,
|
isDefault: config.isDefault,
|
||||||
});
|
});
|
||||||
@@ -151,6 +165,7 @@ export default function EmailProviders() {
|
|||||||
setFormData(emptyForm);
|
setFormData(emptyForm);
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setModalTestResult(null);
|
setModalTestResult(null);
|
||||||
|
setMailAccessResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test für einen gespeicherten Provider in der Liste
|
// 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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -231,6 +297,7 @@ export default function EmailProviders() {
|
|||||||
smtpEncryption: formData.smtpEncryption,
|
smtpEncryption: formData.smtpEncryption,
|
||||||
allowSelfSignedCerts: formData.allowSelfSignedCerts,
|
allowSelfSignedCerts: formData.allowSelfSignedCerts,
|
||||||
systemEmailAddress: formData.systemEmailAddress,
|
systemEmailAddress: formData.systemEmailAddress,
|
||||||
|
customerEmailLabel: formData.customerEmailLabel?.trim() || null,
|
||||||
isActive: formData.isActive,
|
isActive: formData.isActive,
|
||||||
isDefault: formData.isDefault,
|
isDefault: formData.isDefault,
|
||||||
};
|
};
|
||||||
@@ -277,9 +344,9 @@ export default function EmailProviders() {
|
|||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Hier konfigurieren Sie die automatische Erstellung von Stressfrei-Wechseln E-Mail-Adressen.
|
Hier konfigurieren Sie die automatische Erstellung von Kunden-E-Mail-Adressen auf Ihrer
|
||||||
Wenn beim Anlegen einer Stressfrei-Adresse die Option "Bei Provider anlegen" aktiviert ist,
|
eigenen Domain. Wenn beim Anlegen einer Adresse die Option "Bei Provider anlegen"
|
||||||
wird die E-Mail-Weiterleitung automatisch erstellt.
|
aktiviert ist, wird die E-Mail-Weiterleitung automatisch erstellt.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={openCreateModal}>
|
<Button onClick={openCreateModal}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@@ -483,13 +550,37 @@ export default function EmailProviders() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<Input
|
||||||
label="Domain *"
|
label="Bezeichnung für Kunden-E-Mails (UI-Label)"
|
||||||
value={formData.domain}
|
value={formData.customerEmailLabel}
|
||||||
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, customerEmailLabel: e.target.value })}
|
||||||
placeholder="stressfrei-wechseln.de"
|
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'}"`}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
<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
|
<Input
|
||||||
label="Standard-Weiterleitungsadresse"
|
label="Standard-Weiterleitungsadresse"
|
||||||
@@ -659,6 +750,89 @@ export default function EmailProviders() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||||
|
|||||||
@@ -1200,6 +1200,8 @@ export interface EmailProviderConfig {
|
|||||||
// System-E-Mail für automatisierten Versand
|
// System-E-Mail für automatisierten Versand
|
||||||
systemEmailAddress?: string;
|
systemEmailAddress?: string;
|
||||||
systemEmailPasswordEncrypted?: string;
|
systemEmailPasswordEncrypted?: string;
|
||||||
|
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln")
|
||||||
|
customerEmailLabel?: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -1254,10 +1256,35 @@ export const emailProviderApi = {
|
|||||||
const res = await api.post<ApiResponse<EmailOperationResult>>('/email-providers/test-connection', body);
|
const res = await api.post<ApiResponse<EmailOperationResult>>('/email-providers/test-connection', body);
|
||||||
return res.data;
|
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 () => {
|
getDomain: async () => {
|
||||||
const res = await api.get<ApiResponse<{ domain: string | null }>>('/email-providers/domain');
|
const res = await api.get<ApiResponse<{ domain: string | null }>>('/email-providers/domain');
|
||||||
return res.data;
|
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) => {
|
checkEmailExists: async (localPart: string) => {
|
||||||
const res = await api.get<ApiResponse<{ exists: boolean; email?: string }>>(`/email-providers/check/${localPart}`);
|
const res = await api.get<ApiResponse<{ exists: boolean; email?: string }>>(`/email-providers/check/${localPart}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user