first commit

This commit is contained in:
Stefan Hacker
2026-01-29 01:16:54 +01:00
commit e209e9bbca
12105 changed files with 2480672 additions and 0 deletions
@@ -0,0 +1,378 @@
// ==================== EMAIL PROVIDER SERVICE ====================
import { PrismaClient } from '@prisma/client';
import { decrypt } from '../../utils/encryption.js';
import {
IEmailProvider,
EmailProviderConfig,
EmailExistsResult,
EmailOperationResult,
CreateEmailParams,
} from './types.js';
import { PleskEmailProvider } from './pleskProvider.js';
const prisma = new PrismaClient();
// 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;
isActive?: boolean;
isDefault?: boolean;
}
export async function createProviderConfig(data: CreateProviderConfigData) {
// Falls isDefault=true, alle anderen auf false setzen
if (data.isDefault) {
await prisma.emailProviderConfig.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
// Passwort verschlüsseln falls vorhanden
const { encrypt } = await import('../../utils/encryption.js');
const passwordEncrypted = data.password ? encrypt(data.password) : 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: data.domain,
defaultForwardEmail: data.defaultForwardEmail || 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 = data.domain;
if (data.defaultForwardEmail !== undefined)
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
// 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) {
const { encrypt } = await import('../../utils/encryption.js');
updateData.passwordEncrypted = encrypt(data.password);
} else if (data.username !== undefined && !data.username) {
// Benutzername wird gelöscht → Passwort auch löschen
updateData.passwordEncrypted = 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,
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 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,
};
}
}
// 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;
}
// 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,
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,
};
}
}
@@ -0,0 +1,5 @@
// ==================== EMAIL PROVIDER EXPORTS ====================
export * from './types.js';
export * from './emailProviderService.js';
export { PleskEmailProvider } from './pleskProvider.js';
@@ -0,0 +1,348 @@
// ==================== PLESK EMAIL PROVIDER ====================
import { Agent, fetch as undiciFetch } from 'undici';
import {
IEmailProvider,
EmailProviderConfig,
EmailExistsResult,
EmailOperationResult,
CreateEmailParams,
RenameEmailParams,
} from './types.js';
// Undici-Agent der selbstsignierte Zertifikate akzeptiert
// Mit Timeouts und Connection-Limits um Probleme zu vermeiden
const httpsAgent = new Agent({
connect: {
rejectUnauthorized: false,
timeout: 10000, // 10 Sekunden Connect-Timeout
},
bodyTimeout: 30000, // 30 Sekunden für Response-Body
headersTimeout: 30000, // 30 Sekunden für Headers
keepAliveTimeout: 1000, // Connections nach 1 Sekunde schließen
keepAliveMaxTimeout: 5000, // Maximal 5 Sekunden Keep-Alive
connections: 1, // Nur eine Connection gleichzeitig pro Host
pipelining: 1, // Kein Pipelining
});
export class PleskEmailProvider implements IEmailProvider {
readonly type = 'PLESK';
private config: EmailProviderConfig;
constructor(config: EmailProviderConfig) {
this.config = config;
}
// Basis-URL für API-Requests
private get baseUrl(): string {
// Entferne trailing slash falls vorhanden
return this.config.apiUrl.replace(/\/$/, '');
}
// HTTP-Request an Plesk API senden
private async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
endpoint: string,
data?: Record<string, unknown>
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
// Authentifizierung: API-Key hat Priorität, sonst Basic Auth
if (this.config.apiKey) {
// Nur API-Key verwenden (ohne Basic Auth)
headers['X-API-Key'] = this.config.apiKey;
} else if (this.config.username && this.config.password) {
// Basic Auth nur wenn kein API-Key
const authHeader = Buffer.from(
`${this.config.username}:${this.config.password}`
).toString('base64');
headers['Authorization'] = `Basic ${authHeader}`;
} else {
// Keine Authentifizierung vorhanden
throw new Error('Keine Zugangsdaten angegeben - bitte API-Key oder Benutzername/Passwort eingeben');
}
const options: Parameters<typeof undiciFetch>[1] = {
method,
headers,
dispatcher: httpsAgent,
};
if (data && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
try {
const response = await undiciFetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Plesk API Fehler: ${response.status} - ${errorText}`);
}
// Leere Response bei DELETE
if (response.status === 204) {
return {} as T;
}
return await response.json() as T;
} catch (error) {
// Verbesserte Fehlermeldungen für häufige Probleme
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// Netzwerkfehler
if (msg.includes('econnrefused')) {
throw new Error(`Server nicht erreichbar unter ${this.baseUrl} - Ist der Server gestartet?`);
}
if (msg.includes('enotfound') || msg.includes('getaddrinfo')) {
throw new Error(`Server-Adresse nicht gefunden: ${this.baseUrl} - Bitte URL prüfen`);
}
if (msg.includes('etimedout') || msg.includes('timeout')) {
throw new Error(`Zeitüberschreitung bei Verbindung zu ${this.baseUrl}`);
}
if (msg.includes('econnreset')) {
throw new Error(`Verbindung wurde vom Server abgebrochen`);
}
// SSL/TLS Fehler
if (msg.includes('cert') || msg.includes('ssl') || msg.includes('tls') || msg.includes('unable_to_verify')) {
throw new Error(`SSL-Zertifikatsfehler - Selbstsigniertes Zertifikat wird nicht akzeptiert`);
}
// fetch failed ist meist ein Netzwerk/SSL Problem
if (msg.includes('fetch failed')) {
throw new Error(`Verbindung fehlgeschlagen zu ${this.baseUrl} - Bitte prüfen: Server erreichbar? HTTPS-Port korrekt?`);
}
}
console.error('Plesk API Request failed:', error);
throw error;
}
}
async testConnection(): Promise<void> {
// Versuche Server-Info abzurufen - wirft Fehler bei Auth-Problemen
try {
await this.request('GET', '/api/v2/server');
} catch (error) {
if (error instanceof Error) {
// Verbesserte Fehlermeldung
if (error.message.includes('401')) {
throw new Error('Authentifizierung fehlgeschlagen - Benutzername/Passwort oder API-Key prüfen');
}
if (error.message.includes('403')) {
throw new Error('Zugriff verweigert - Berechtigungen prüfen');
}
// Andere Fehler wurden schon in request() übersetzt
}
throw error;
}
}
async emailExists(localPart: string): Promise<EmailExistsResult> {
const email = `${localPart}@${this.config.domain}`;
try {
// Plesk CLI API: Mail-Info abfragen
const result = await this.request<{ code: number; stdout: string; stderr: string }>(
'POST',
'/api/v2/cli/mail/call',
{ params: ['--info', email] }
);
// Debug: Response-Struktur loggen
console.log('Plesk emailExists response:', JSON.stringify(result, null, 2));
// Plesk gibt code=0 bei Erfolg, code!=0 bei Fehler
// stderr enthält Fehlermeldung wenn Mail nicht existiert
const hasError = result.code !== 0 ||
result.stderr?.toLowerCase().includes('not found') ||
result.stderr?.toLowerCase().includes('does not exist') ||
result.stderr?.toLowerCase().includes('unable to find') ||
result.stderr?.toLowerCase().includes('no such');
if (hasError) {
return { exists: false };
}
// stdout sollte die Mail-Infos enthalten
const exists = result.stdout?.toLowerCase().includes(localPart.toLowerCase());
return {
exists,
email: exists ? email : undefined,
};
} catch (error) {
// HTTP-Fehler oder Netzwerkfehler
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// "not found" = Mail gibt es nicht
if (msg.includes('not found') || msg.includes('does not exist') || msg.includes('unable to find')) {
return { exists: false };
}
}
console.error('Plesk emailExists error:', error);
return { exists: false };
}
}
async createEmail(params: CreateEmailParams): Promise<EmailOperationResult> {
const { localPart, forwardTargets } = params;
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob schon existiert
const exists = await this.emailExists(localPart);
if (exists.exists) {
return {
success: false,
error: `E-Mail ${email} existiert bereits`,
};
}
// Plesk CLI API: Mail-Account mit Weiterleitung erstellen
// Verwendet den CLI-Wrapper unter /api/v2/cli/mail/call
// Format für -forwarding-addresses: "add:email1,email2" oder "set:email1,email2"
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
'--create', email,
'-forwarding', 'true',
'-forwarding-addresses', `add:${forwardTargets.join(',')}`,
'-mailbox', 'false',
],
});
return {
success: true,
message: `E-Mail ${email} erfolgreich erstellt mit Weiterleitung an: ${forwardTargets.join(', ')}`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk createEmail error:', error);
return {
success: false,
error: `Fehler beim Erstellen der E-Mail: ${errorMessage}`,
};
}
}
async deleteEmail(localPart: string): Promise<EmailOperationResult> {
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob Mail existiert
const exists = await this.emailExists(localPart);
if (!exists.exists) {
return {
success: false,
error: `E-Mail ${email} nicht gefunden`,
};
}
// Plesk CLI API: Mail-Account löschen
await this.request('POST', '/api/v2/cli/mail/call', {
params: ['--remove', email],
});
return {
success: true,
message: `E-Mail ${email} erfolgreich gelöscht`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk deleteEmail error:', error);
return {
success: false,
error: `Fehler beim Löschen der E-Mail: ${errorMessage}`,
};
}
}
async renameEmail(params: RenameEmailParams): Promise<EmailOperationResult> {
const { oldLocalPart, newLocalPart } = params;
const oldEmail = `${oldLocalPart}@${this.config.domain}`;
const newEmail = `${newLocalPart}@${this.config.domain}`;
try {
// Prüfen ob alte Mail existiert
const oldExists = await this.emailExists(oldLocalPart);
if (!oldExists.exists) {
return {
success: false,
error: `E-Mail ${oldEmail} nicht gefunden`,
};
}
// Prüfen ob neue Adresse schon existiert
const newExists = await this.emailExists(newLocalPart);
if (newExists.exists) {
return {
success: false,
error: `E-Mail ${newEmail} existiert bereits`,
};
}
// Plesk CLI API: Mail-Account umbenennen
await this.request('POST', '/api/v2/cli/mail/call', {
params: ['--rename', oldEmail, '-new-name', newLocalPart],
});
return {
success: true,
message: `E-Mail erfolgreich umbenannt von ${oldEmail} zu ${newEmail}`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk renameEmail error:', error);
return {
success: false,
error: `Fehler beim Umbenennen der E-Mail: ${errorMessage}`,
};
}
}
async updateForwardTargets(
localPart: string,
targets: string[]
): Promise<EmailOperationResult> {
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob Mail existiert
const exists = await this.emailExists(localPart);
if (!exists.exists) {
return {
success: false,
error: `E-Mail ${email} nicht gefunden`,
};
}
// Plesk CLI API: Weiterleitungsziele aktualisieren
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
'--update', email,
'-forwarding', 'true',
'-forwarding-addresses', `set:${targets.join(',')}`,
],
});
return {
success: true,
message: `Weiterleitungen für ${email} aktualisiert: ${targets.join(', ')}`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk updateForwardTargets error:', error);
return {
success: false,
error: `Fehler beim Aktualisieren der Weiterleitungen: ${errorMessage}`,
};
}
}
}
@@ -0,0 +1,65 @@
// ==================== EMAIL PROVIDER TYPES ====================
export interface EmailForwardTarget {
email: string;
}
export interface CreateEmailParams {
localPart: string; // z.B. "max.mustermann"
forwardTargets: string[]; // Weiterleitungsziele
}
export interface RenameEmailParams {
oldLocalPart: string;
newLocalPart: string;
}
export interface EmailExistsResult {
exists: boolean;
email?: string;
}
export interface EmailOperationResult {
success: boolean;
message?: string;
error?: string;
}
// Interface das alle Email-Provider implementieren müssen
export interface IEmailProvider {
// Provider-Typ (z.B. 'PLESK', 'CPANEL')
readonly type: string;
// Testet die Verbindung zum Provider (wirft Fehler bei Fehlschlag)
testConnection(): Promise<void>;
// Prüft ob eine E-Mail-Adresse existiert
emailExists(localPart: string): Promise<EmailExistsResult>;
// Erstellt eine neue E-Mail-Weiterleitung
createEmail(params: CreateEmailParams): Promise<EmailOperationResult>;
// Löscht eine E-Mail-Adresse
deleteEmail(localPart: string): Promise<EmailOperationResult>;
// Benennt eine E-Mail-Adresse um
renameEmail(params: RenameEmailParams): Promise<EmailOperationResult>;
// Aktualisiert die Weiterleitungsziele
updateForwardTargets(localPart: string, targets: string[]): Promise<EmailOperationResult>;
}
// Konfiguration für Provider (aus DB)
export interface EmailProviderConfig {
id: number;
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string; // Entschlüsselt
domain: string;
defaultForwardEmail?: string;
isActive: boolean;
isDefault: boolean;
}