// ==================== PLESK EMAIL PROVIDER ==================== import { Agent, fetch as undiciFetch } from 'undici'; import { IEmailProvider, EmailProviderConfig, EmailExistsResult, EmailOperationResult, CreateEmailParams, CreateEmailWithMailboxParams, CreateEmailWithMailboxResult, EnableMailboxParams, UpdateMailboxPasswordParams, 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( method: 'GET' | 'POST' | 'PUT' | 'DELETE', endpoint: string, data?: Record ): Promise { const url = `${this.baseUrl}${endpoint}`; const headers: Record = { '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[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 { // 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 { 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()); // Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false") let hasMailbox: boolean | undefined; let mailgroupActive: boolean | undefined; let mailgroupMembers: string[] | undefined; let forwardingActive: boolean | undefined; let forwardingTargets: string[] | undefined; if (exists && result.stdout) { const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i); if (mailboxMatch) { hasMailbox = mailboxMatch[1].toLowerCase() === 'true'; } // Mailgroup-Status + Mitglieder. Plesk listet sie auf einer // Zeile, Adressen sind durch Whitespace getrennt. const mailgroupMatch = result.stdout.match(/Mailgroup:\s*(true|false)/i); if (mailgroupMatch) { mailgroupActive = mailgroupMatch[1].toLowerCase() === 'true'; } const groupMembersMatch = result.stdout.match(/Group member\(s\):\s*([^\n]*)/i); if (groupMembersMatch) { mailgroupMembers = groupMembersMatch[1] .trim() .split(/\s+/) .filter((m) => m.includes('@')); } // Forwarding-Status + Ziele. Plesk druckt "Forward request: ". // Auf manchen Plesk-Versionen heißt das Feld auch "Forwarding". const forwardActiveMatch = result.stdout.match(/Forwarding:\s*(true|false)/i); if (forwardActiveMatch) { forwardingActive = forwardActiveMatch[1].toLowerCase() === 'true'; } const forwardTargetsMatch = result.stdout.match(/Forward(?:ing)?(?: request)?:\s*([^\n]*)/i); if (forwardTargetsMatch) { forwardingTargets = forwardTargetsMatch[1] .trim() .split(/\s+/) .filter((m) => m.includes('@')); if (forwardingActive === undefined) { forwardingActive = (forwardingTargets?.length ?? 0) > 0; } } } return { exists, email: exists ? email : undefined, hasMailbox, mailgroupActive, mailgroupMembers, forwardingActive, forwardingTargets, }; } 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 { 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 createEmailWithMailbox(params: CreateEmailWithMailboxParams): Promise { const { localPart, forwardTargets, password } = 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 echter Mailbox erstellen // -mailbox true: Echte Mailbox (IMAP/SMTP-Zugang) // -passwd: Passwort für die Mailbox // -forwarding true: Zusätzlich Weiterleitung aktivieren await this.request('POST', '/api/v2/cli/mail/call', { params: [ '--create', email, '-mailbox', 'true', '-passwd', password, '-forwarding', 'true', '-forwarding-addresses', `add:${forwardTargets.join(',')}`, ], }); return { success: true, message: `E-Mail ${email} mit Mailbox erfolgreich erstellt`, email, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; console.error('Plesk createEmailWithMailbox error:', error); return { success: false, error: `Fehler beim Erstellen der E-Mail mit Mailbox: ${errorMessage}`, }; } } async enableMailboxForExisting(params: EnableMailboxParams): Promise { const { localPart, password } = params; const email = `${localPart}@${this.config.domain}`; try { // Prüfen ob E-Mail existiert const exists = await this.emailExists(localPart); if (!exists.exists) { return { success: false, error: `E-Mail ${email} nicht gefunden`, }; } // Plesk CLI API: Mailbox für existierende E-Mail aktivieren // --update: Existierende E-Mail aktualisieren // -mailbox true: Mailbox aktivieren // -passwd: Passwort für die Mailbox setzen await this.request('POST', '/api/v2/cli/mail/call', { params: [ '--update', email, '-mailbox', 'true', '-passwd', password, ], }); return { success: true, message: `Mailbox für ${email} erfolgreich aktiviert`, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; console.error('Plesk enableMailboxForExisting error:', error); return { success: false, error: `Fehler beim Aktivieren der Mailbox: ${errorMessage}`, }; } } async updateMailboxPassword(params: UpdateMailboxPasswordParams): Promise { const { localPart, password } = params; const email = `${localPart}@${this.config.domain}`; try { // Prüfen ob E-Mail existiert const exists = await this.emailExists(localPart); if (!exists.exists) { return { success: false, error: `E-Mail ${email} nicht gefunden`, }; } // Plesk CLI API: Passwort für existierende E-Mail aktualisieren // --update: Existierende E-Mail aktualisieren // -passwd: Neues Passwort setzen await this.request('POST', '/api/v2/cli/mail/call', { params: [ '--update', email, '-passwd', password, ], }); return { success: true, message: `Passwort für ${email} erfolgreich aktualisiert`, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; console.error('Plesk updateMailboxPassword error:', error); return { success: false, error: `Fehler beim Aktualisieren des Passworts: ${errorMessage}`, }; } } async deleteEmail(localPart: string): Promise { 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 { 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 { 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-Eigenheit: `-forwarding-addresses` akzeptiert NUR // `add:` und `del:`, KEIN `set:`. Und `-forwarding` ist der // Mailgroup-Schalter (Plesk nennt das im `--info` "Mailgroup", // im CLI "forwarding" – derselbe Mechanismus, doppelt benannt). // Es gibt KEINE separaten Mailgroup-Optionen wie `-mailgroup`. // // Wir bauen daher den Diff: alte Member abrufen, dann // del: + add: in zwei separaten Calls. Idempotent, // weil add: Duplikate ignoriert und del: nicht-vorhandene auch. const currentMembers = exists.mailgroupMembers ?? []; const targetsLower = new Set(targets.map((t) => t.toLowerCase())); const currentLower = new Set(currentMembers.map((m) => m.toLowerCase())); const toRemove = currentMembers.filter((m) => !targetsLower.has(m.toLowerCase())); const toAdd = targets.filter((t) => !currentLower.has(t.toLowerCase())); console.log( `[Plesk updateForwardTargets] ${email} – aktuell: [${currentMembers.join(', ')}], ` + `soll: [${targets.join(', ')}], entfernen: [${toRemove.join(', ')}], hinzufügen: [${toAdd.join(', ')}]`, ); // Entfernen-Schritt if (toRemove.length > 0) { const delParams = [ '--update', email, '-forwarding-addresses', `del:${toRemove.join(',')}`, ]; const delResult = await this.request<{ code: number; stdout: string; stderr: string }>( 'POST', '/api/v2/cli/mail/call', { params: delParams }, ); console.log('[Plesk updateForwardTargets] del response:', JSON.stringify(delResult, null, 2)); if (delResult.code !== 0 || /error|failed/i.test(delResult.stderr || '')) { return { success: false, error: delResult.stderr?.trim() || delResult.stdout?.trim() || `Plesk del returned code ${delResult.code}`, }; } } // Hinzufügen-Schritt (impliziert -forwarding true, damit Mailgroup // aktiviert bleibt bzw. wird). if (toAdd.length > 0) { const addParams = [ '--update', email, '-forwarding', 'true', '-forwarding-addresses', `add:${toAdd.join(',')}`, ]; const addResult = await this.request<{ code: number; stdout: string; stderr: string }>( 'POST', '/api/v2/cli/mail/call', { params: addParams }, ); console.log('[Plesk updateForwardTargets] add response:', JSON.stringify(addResult, null, 2)); if (addResult.code !== 0 || /error|failed/i.test(addResult.stderr || '')) { return { success: false, error: addResult.stderr?.trim() || addResult.stdout?.trim() || `Plesk add returned code ${addResult.code}`, }; } } 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}`, }; } } }