Files
opencrm/backend/src/services/emailProvider/pleskProvider.ts
T
duffyduck 194c86409f Plesk-Sync: del/add-Diff statt nicht-existierendem set:
Follow-up zu a83358b/24e152b. plesk bin mail --help auf Prod zeigt:
- -forwarding-addresses akzeptiert NUR add: und del:, kein set:
  → unser set:-Befehl wurde silent verworfen, Sync hatte nie
  Wirkung.
- -mailgroup als Option existiert gar nicht. Plesk nutzt -forwarding
  als Mailgroup-Schalter (im --info als "Mailgroup:" ausgegeben, im
  CLI als "-forwarding" gesetzt). Mein vorheriges -mailgroup false
  triggerte "Unrecognized option".

updateForwardTargets jetzt:
1. Aktuelle Members aus emailExists holen
2. Diff: toRemove = current \ targets, toAdd = targets \ current
   (case-insensitive)
3. Wenn toRemove: --update -forwarding-addresses del:<liste>
4. Wenn toAdd:    --update -forwarding true -forwarding-addresses add:<liste>

Idempotent, weil add/del Duplikate bzw. nicht-existente ignorieren.

Smoke-Test mit Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
nichts entfernt, nur bzirks@gmx.de hinzugefügt.
2026-06-18 18:16:57 +02:00

572 lines
20 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.
// ==================== 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<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());
// 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: <addrs>".
// 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<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 createEmailWithMailbox(params: CreateEmailWithMailboxParams): Promise<CreateEmailWithMailboxResult> {
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<EmailOperationResult> {
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<EmailOperationResult> {
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<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-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:<entfernt> + add:<neu> 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}`,
};
}
}
}