Files
opencrm/backend/src/services/emailProvider/emailProviderService.ts
T
duffyduck b4be3cebfb feat(stressfrei): Weiterleitungen manuell synchronisieren
Nach Änderung der Kunden-Stamm-E-Mail (oder der defaultForwardEmail in
den Provider-Settings) müssen die Plesk-Forwards der Stressfrei-Adressen
des Kunden auf den neuen Wert umgestellt werden. Bisher ging das nur
manuell pro Adresse im Plesk-UI – jetzt mit einem Klick pro Adresse im
CRM.

Backend:
- emailProviderService.setEmailForwardTargets(localPart, targets[]):
  dünner Wrapper um die schon vorhandene IEmailProvider-Methode
  updateForwardTargets (`set:email1,email2` ersetzt komplett, idempotent)
- stressfreiEmail.service.syncForwardingForEmail(id): lädt Kunde +
  Provider-Config, baut [customer.email, defaultForwardEmail] und ruft
  den Provider auf
- POST /api/stressfrei-emails/:id/sync-forwarding, customers:update,
  Audit-Log mit den neuen Forward-Targets im Label

Frontend:
- Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse,
  sichtbar nur wenn isProvisioned (sonst sinnlos). Confirm-Dialog
  zeigt die Ziele, Tooltip erklärt den Vorgang.
- ExternalLink-Icon neben der E-Mail in der Kundenakte (Stammdaten →
  Kontakt) öffnet den Stressfrei-Tab des Kunden in neuem Tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:53:48 +02:00

693 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==================== EMAIL PROVIDER SERVICE ====================
import prisma from '../../lib/prisma.js';
import { decrypt } from '../../utils/encryption.js';
import {
IEmailProvider,
EmailProviderConfig,
EmailExistsResult,
EmailOperationResult,
CreateEmailParams,
MailEncryption,
} from './types.js';
import { PleskEmailProvider } from './pleskProvider.js';
// Factory-Funktion um den richtigen Provider zu erstellen
function createProvider(config: EmailProviderConfig): IEmailProvider {
switch (config.type) {
case 'PLESK':
return new PleskEmailProvider(config);
case 'CPANEL':
// TODO: cPanel Provider implementieren
throw new Error('cPanel Provider noch nicht implementiert');
case 'DIRECTADMIN':
// TODO: DirectAdmin Provider implementieren
throw new Error('DirectAdmin Provider noch nicht implementiert');
default:
throw new Error(`Unbekannter Provider-Typ: ${config.type}`);
}
}
// ==================== CONFIG CRUD ====================
export async function getAllProviderConfigs() {
return prisma.emailProviderConfig.findMany({
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
}
export async function getProviderConfigById(id: number) {
return prisma.emailProviderConfig.findUnique({
where: { id },
});
}
export async function getDefaultProviderConfig() {
return prisma.emailProviderConfig.findFirst({
where: { isActive: true, isDefault: true },
});
}
export async function getActiveProviderConfig() {
// Erst Default-Provider versuchen, dann irgendeinen aktiven
const defaultProvider = await getDefaultProviderConfig();
if (defaultProvider) return defaultProvider;
return prisma.emailProviderConfig.findFirst({
where: { isActive: true },
});
}
export interface CreateProviderConfigData {
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string;
domain: string;
defaultForwardEmail?: string;
// Verschlüsselungs-Einstellungen
imapEncryption?: MailEncryption;
smtpEncryption?: MailEncryption;
allowSelfSignedCerts?: boolean;
// 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({
where: { isDefault: true },
data: { isDefault: false },
});
}
// Passwörter verschlüsseln falls vorhanden
const { encrypt } = await import('../../utils/encryption.js');
const passwordEncrypted = data.password ? encrypt(data.password) : null;
const systemEmailPasswordEncrypted = data.systemEmailPassword ? encrypt(data.systemEmailPassword) : null;
return prisma.emailProviderConfig.create({
data: {
name: data.name,
type: data.type,
apiUrl: data.apiUrl,
apiKey: data.apiKey || null,
username: data.username || null,
passwordEncrypted,
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,
},
});
}
export async function updateProviderConfig(
id: number,
data: Partial<CreateProviderConfigData>
) {
// Falls isDefault=true, alle anderen auf false setzen
if (data.isDefault) {
await prisma.emailProviderConfig.updateMany({
where: { isDefault: true, id: { not: id } },
data: { isDefault: false },
});
}
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.type !== undefined) updateData.type = data.type;
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 = 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;
const { encrypt } = await import('../../utils/encryption.js');
// Passwort-Logik:
// - Wenn neues Passwort übergeben → verschlüsseln und speichern
// - Wenn Benutzername gelöscht wird → Passwort auch löschen (gehören zusammen)
if (data.password) {
updateData.passwordEncrypted = encrypt(data.password);
} else if (data.username !== undefined && !data.username) {
// Benutzername wird gelöscht → Passwort auch löschen
updateData.passwordEncrypted = null;
}
// System-E-Mail-Passwort
if (data.systemEmailPassword) {
updateData.systemEmailPasswordEncrypted = encrypt(data.systemEmailPassword);
} else if (data.systemEmailAddress !== undefined && !data.systemEmailAddress) {
// System-E-Mail wird gelöscht → Passwort auch löschen
updateData.systemEmailPasswordEncrypted = null;
}
return prisma.emailProviderConfig.update({
where: { id },
data: updateData,
});
}
export async function deleteProviderConfig(id: number) {
return prisma.emailProviderConfig.delete({
where: { id },
});
}
// ==================== EMAIL OPERATIONS ====================
// Provider-Instanz aus DB-Config erstellen
async function getProviderInstance(): Promise<IEmailProvider> {
const dbConfig = await getActiveProviderConfig();
if (!dbConfig) {
throw new Error('Kein aktiver Email-Provider konfiguriert');
}
// Passwort entschlüsseln
let password: string | undefined;
if (dbConfig.passwordEncrypted) {
try {
password = decrypt(dbConfig.passwordEncrypted);
} catch {
console.error('Konnte Passwort nicht entschlüsseln');
}
}
const config: EmailProviderConfig = {
id: dbConfig.id,
name: dbConfig.name,
type: dbConfig.type as 'PLESK' | 'CPANEL' | 'DIRECTADMIN',
apiUrl: dbConfig.apiUrl,
apiKey: dbConfig.apiKey || undefined,
username: dbConfig.username || undefined,
password,
domain: dbConfig.domain,
defaultForwardEmail: dbConfig.defaultForwardEmail || undefined,
imapServer: dbConfig.imapServer || undefined,
imapPort: dbConfig.imapPort || undefined,
smtpServer: dbConfig.smtpServer || undefined,
smtpPort: dbConfig.smtpPort || undefined,
imapEncryption: dbConfig.imapEncryption as MailEncryption,
smtpEncryption: dbConfig.smtpEncryption as MailEncryption,
allowSelfSignedCerts: dbConfig.allowSelfSignedCerts,
isActive: dbConfig.isActive,
isDefault: dbConfig.isDefault,
};
return createProvider(config);
}
// Prüfen ob eine E-Mail existiert
export async function checkEmailExists(localPart: string): Promise<EmailExistsResult> {
try {
const provider = await getProviderInstance();
return provider.emailExists(localPart);
} catch (error) {
console.error('checkEmailExists error:', error);
return { exists: false };
}
}
// E-Mail erstellen mit Weiterleitungen
export async function provisionEmail(
localPart: string,
customerEmail: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
const config = await getActiveProviderConfig();
// Weiterleitungsziele zusammenstellen
const forwardTargets: string[] = [customerEmail];
// Unsere eigene Weiterleitungsadresse hinzufügen falls konfiguriert
if (config?.defaultForwardEmail) {
forwardTargets.push(config.defaultForwardEmail);
}
// Prüfen ob existiert
const exists = await provider.emailExists(localPart);
if (exists.exists) {
return {
success: true,
message: `E-Mail ${exists.email} existiert bereits`,
};
}
// Erstellen
const result = await provider.createEmail({
localPart,
forwardTargets,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// E-Mail mit echter Mailbox erstellen (IMAP/SMTP-Zugang)
export async function provisionEmailWithMailbox(
localPart: string,
customerEmail: string,
password: string
): Promise<EmailOperationResult & { email?: string }> {
try {
const provider = await getProviderInstance();
const config = await getActiveProviderConfig();
// Weiterleitungsziele zusammenstellen
const forwardTargets: string[] = [customerEmail];
// Unsere eigene Weiterleitungsadresse hinzufügen falls konfiguriert
if (config?.defaultForwardEmail) {
forwardTargets.push(config.defaultForwardEmail);
}
// Prüfen ob existiert
const exists = await provider.emailExists(localPart);
if (exists.exists) {
return {
success: true,
message: `E-Mail ${exists.email} existiert bereits`,
email: exists.email,
};
}
// Mit Mailbox erstellen
const result = await provider.createEmailWithMailbox({
localPart,
forwardTargets,
password,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// Mailbox für existierende E-Mail-Weiterleitung aktivieren
export async function enableMailboxForExistingEmail(
localPart: string,
password: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
const result = await provider.enableMailboxForExisting({
localPart,
password,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// Mailbox-Passwort beim Provider aktualisieren
export async function updateMailboxPassword(
localPart: string,
password: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
const result = await provider.updateMailboxPassword({
localPart,
password,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// IMAP/SMTP-Einstellungen vom aktiven Provider holen
export interface ImapSmtpSettings {
imapServer: string;
imapPort: number;
imapEncryption: MailEncryption; // SSL, STARTTLS oder NONE
smtpServer: string;
smtpPort: number;
smtpEncryption: MailEncryption; // SSL, STARTTLS oder NONE
allowSelfSignedCerts: boolean; // Selbstsignierte Zertifikate erlauben
domain: string;
}
export async function getImapSmtpSettings(): Promise<ImapSmtpSettings | null> {
const config = await getActiveProviderConfig();
if (!config) return null;
// Default-Server: Hostname aus der apiUrl extrahieren (z.B. rs001871.fastrootserver.de aus https://rs001871.fastrootserver.de:8443)
// Der Plesk-Server ist gleichzeitig der Mail-Server
let defaultServer: string;
try {
const url = new URL(config.apiUrl);
defaultServer = url.hostname;
} catch {
// Fallback falls apiUrl ungültig
defaultServer = `mail.${config.domain}`;
}
// Verschlüsselungs-Einstellungen
const imapEncryption = (config.imapEncryption ?? 'SSL') as MailEncryption;
const smtpEncryption = (config.smtpEncryption ?? 'SSL') as MailEncryption;
// Ports basierend auf Verschlüsselung berechnen:
// SSL: IMAP 993, SMTP 465
// STARTTLS: IMAP 143, SMTP 587
// NONE: IMAP 143, SMTP 25
//
// Standard-Ports werden IMMER basierend auf Verschlüsselung berechnet.
// Nur benutzerdefinierte Ports (nicht 993/143/465/587/25) werden aus der DB übernommen.
const getImapPort = (enc: MailEncryption, storedPort: number | null) => {
const standardPorts = [993, 143];
// Wenn ein nicht-standard Port gespeichert ist, diesen verwenden
if (storedPort && !standardPorts.includes(storedPort)) {
return storedPort;
}
// Sonst basierend auf Verschlüsselung
return enc === 'SSL' ? 993 : 143;
};
const getSmtpPort = (enc: MailEncryption, storedPort: number | null) => {
const standardPorts = [465, 587, 25];
// Wenn ein nicht-standard Port gespeichert ist, diesen verwenden
if (storedPort && !standardPorts.includes(storedPort)) {
return storedPort;
}
// Sonst basierend auf Verschlüsselung
if (enc === 'SSL') return 465;
if (enc === 'STARTTLS') return 587;
return 25; // NONE
};
return {
imapServer: config.imapServer || defaultServer,
imapPort: getImapPort(imapEncryption, config.imapPort),
imapEncryption,
smtpServer: config.smtpServer || defaultServer,
smtpPort: getSmtpPort(smtpEncryption, config.smtpPort),
smtpEncryption,
allowSelfSignedCerts: config.allowSelfSignedCerts ?? false,
domain: config.domain,
};
}
// E-Mail löschen
export async function deprovisionEmail(localPart: string): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
return provider.deleteEmail(localPart);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// Weiterleitungsziele ersetzen (set:, nicht add:) nutzen wir, um nach einer
// Kunden-Email-Änderung die Forwards einer Stressfrei-Adresse auf den neuen
// Kunden-Inbox + unsere Service-Adresse zu setzen.
export async function setEmailForwardTargets(
localPart: string,
targets: string[],
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
return provider.updateForwardTargets(localPart, targets);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return { success: false, error: errorMessage };
}
}
// E-Mail umbenennen
export async function renameProvisionedEmail(
oldLocalPart: string,
newLocalPart: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
return provider.renameEmail({ oldLocalPart, newLocalPart });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// Domain aus aktivem Provider holen
export async function getProviderDomain(): Promise<string | null> {
const config = await getActiveProviderConfig();
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';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string;
domain: string;
}): IEmailProvider {
const config: EmailProviderConfig = {
id: 0,
name: 'Test',
type: data.type,
apiUrl: data.apiUrl,
apiKey: data.apiKey,
username: data.username,
password: data.password,
domain: data.domain,
isActive: true,
isDefault: false,
};
return createProvider(config);
}
// Provider-Instanz aus DB-Config per ID erstellen
async function getProviderInstanceById(id: number): Promise<IEmailProvider> {
const dbConfig = await getProviderConfigById(id);
if (!dbConfig) {
throw new Error('Email-Provider nicht gefunden');
}
// Passwort entschlüsseln
let password: string | undefined;
if (dbConfig.passwordEncrypted) {
try {
password = decrypt(dbConfig.passwordEncrypted);
} catch {
console.error('Konnte Passwort nicht entschlüsseln');
}
}
const config: EmailProviderConfig = {
id: dbConfig.id,
name: dbConfig.name,
type: dbConfig.type as 'PLESK' | 'CPANEL' | 'DIRECTADMIN',
apiUrl: dbConfig.apiUrl,
apiKey: dbConfig.apiKey || undefined,
username: dbConfig.username || undefined,
password,
domain: dbConfig.domain,
defaultForwardEmail: dbConfig.defaultForwardEmail || undefined,
imapServer: dbConfig.imapServer || undefined,
imapPort: dbConfig.imapPort || undefined,
smtpServer: dbConfig.smtpServer || undefined,
smtpPort: dbConfig.smtpPort || undefined,
imapEncryption: dbConfig.imapEncryption as MailEncryption,
smtpEncryption: dbConfig.smtpEncryption as MailEncryption,
allowSelfSignedCerts: dbConfig.allowSelfSignedCerts,
isActive: dbConfig.isActive,
isDefault: dbConfig.isDefault,
};
return createProvider(config);
}
// Provider-Verbindung testen (mit ID, Formulardaten oder Default-Provider)
export async function testProviderConnection(options?: {
id?: number;
testData?: {
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string;
domain: string;
};
}): Promise<EmailOperationResult> {
try {
let provider: IEmailProvider;
if (options?.testData) {
// Mit übergebenen Daten testen (z.B. aus Modal beim Neuanlegen)
provider = createProviderFromFormData(options.testData);
} else if (options?.id) {
// Gespeicherten Provider per ID testen
provider = await getProviderInstanceById(options.id);
} else {
// Default-Provider testen
provider = await getProviderInstance();
}
// Expliziter Verbindungstest (wirft Fehler bei Auth-Problemen)
await provider.testConnection();
return {
success: true,
message: 'Verbindung zum Email-Provider erfolgreich',
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// ==================== SYSTEM EMAIL ====================
export interface SystemEmailCredentials {
emailAddress: string;
password: string;
smtpServer: string;
smtpPort: number;
smtpEncryption: MailEncryption;
allowSelfSignedCerts: boolean;
}
/**
* System-E-Mail-Credentials vom aktiven Provider holen.
* Wird für automatisierten Versand (DSGVO, Benachrichtigungen etc.) verwendet.
*/
export async function getSystemEmailCredentials(): Promise<SystemEmailCredentials | null> {
const config = await getActiveProviderConfig();
if (!config?.systemEmailAddress || !config?.systemEmailPasswordEncrypted) {
return null;
}
let password: string;
try {
password = decrypt(config.systemEmailPasswordEncrypted);
} catch {
console.error('System-E-Mail-Passwort konnte nicht entschlüsselt werden');
return null;
}
const settings = await getImapSmtpSettings();
if (!settings) return null;
return {
emailAddress: config.systemEmailAddress,
password,
smtpServer: settings.smtpServer,
smtpPort: settings.smtpPort,
smtpEncryption: settings.smtpEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
}