dfe2a4b241
Folge-Bug zu 194c864: User löscht Adresse im Modal → DB-Liste
wird kürzer → Plesk-Sync läuft → Auto-Import sieht "c ist in
Plesk aber nicht in DB" → schreibt c zurück in
additionalForwardingEmails → Diff sagt nichts zu entfernen.
Ursache: Auto-Import (Pentest 83.x) lief für alle Sync-Pfade.
Beim Sync-Button ist Plesk→DB-Übernahme gewollt (Bestands-
Migration). Beim User-Add/Remove ist die DB-Liste die explizite
Intent – Auto-Import macht das User-Delete kaputt.
syncForwardingForEmail(id, opts?: { autoImportPleskMembers? })
mit Default true (Sync-Button-Verhalten). setAdditionalForwards
ruft mit false – entfernte Adressen verschwinden jetzt sauber
auch beim Provider.
735 lines
25 KiB
TypeScript
735 lines
25 KiB
TypeScript
import prisma from '../lib/prisma.js';
|
||
import { encrypt, decrypt } from '../utils/encryption.js';
|
||
import {
|
||
provisionEmail,
|
||
provisionEmailWithMailbox,
|
||
enableMailboxForExistingEmail,
|
||
checkEmailExists,
|
||
getProviderDomain,
|
||
updateMailboxPassword,
|
||
setEmailForwardTargets,
|
||
getActiveProviderConfig,
|
||
} from './emailProvider/emailProviderService.js';
|
||
import { generateSecurePassword } from '../utils/passwordGenerator.js';
|
||
import { ApiError } from '../utils/apiError.js';
|
||
|
||
// Locker, aber strikt genug gegen offensichtlichen Müll (CRLF, Spaces,
|
||
// Komma). Wirklich validiert wird vom Provider beim Sync.
|
||
const FORWARD_EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
||
|
||
// Pentest 71.1 (MEDIUM, 2026-06-08): RFC-reservierte und private/
|
||
// On-Prem-TLDs. Eine Weiterleitung an `attacker@plesk.internal` würde
|
||
// am Provider DNS-Lookups gegen internes Netz auslösen oder bei mDNS-
|
||
// Setup an einen lokalen Mailserver gehen. Wir blocken sie hart.
|
||
const BLOCKED_TLDS = new Set([
|
||
'local', 'internal', 'corp', 'lan', 'home', 'private',
|
||
'invalid', 'test', 'localhost', 'example',
|
||
'intranet', 'localdomain', 'arpa',
|
||
]);
|
||
|
||
export function parseAdditionalForwards(raw: string | null | undefined): string[] {
|
||
if (!raw) return [];
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (!Array.isArray(parsed)) return [];
|
||
return parsed.filter((x): x is string => typeof x === 'string' && x.trim() !== '');
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function serializeAdditionalForwards(list: string[]): string | null {
|
||
const cleaned = list.map((s) => s.trim()).filter((s) => s !== '');
|
||
return cleaned.length === 0 ? null : JSON.stringify(cleaned);
|
||
}
|
||
|
||
/**
|
||
* Liefert eine normalisierte Form der Adresse für den Dedup-Vergleich:
|
||
* lowercase + Plus-Tag aus dem Local-Part rausgestrippt.
|
||
* `billing+pentest@test.de` und `billing@test.de` haben so denselben
|
||
* Schlüssel und treffen sich beim Dedup. Pentest 71.2.
|
||
*/
|
||
export function canonicalEmailKey(email: string): string {
|
||
const trimmed = email.trim().toLowerCase();
|
||
const at = trimmed.lastIndexOf('@');
|
||
if (at < 1) return trimmed;
|
||
const localPart = trimmed.slice(0, at);
|
||
const domain = trimmed.slice(at + 1);
|
||
const plus = localPart.indexOf('+');
|
||
const cleanedLocal = plus === -1 ? localPart : localPart.slice(0, plus);
|
||
return `${cleanedLocal}@${domain}`;
|
||
}
|
||
|
||
export function assertValidForwardingEmail(value: unknown): string {
|
||
if (typeof value !== 'string') {
|
||
throw new ApiError(400, 'Ungültige Weiterleitungs-E-Mail-Adresse');
|
||
}
|
||
const trimmed = value.trim();
|
||
if (trimmed.length === 0 || trimmed.length > 254) {
|
||
throw new ApiError(400, 'Weiterleitungs-E-Mail-Adresse ist leer oder zu lang');
|
||
}
|
||
if (!FORWARD_EMAIL_REGEX.test(trimmed)) {
|
||
throw new ApiError(400, `Ungültiges E-Mail-Format: ${trimmed}`);
|
||
}
|
||
// 71.1: TLD aus dem Domain-Part rausziehen und gegen die Blocklist
|
||
// halten. Domain liegt nach dem letzten @, TLD nach dem letzten Punkt.
|
||
const domain = trimmed.slice(trimmed.lastIndexOf('@') + 1).toLowerCase();
|
||
const tld = domain.slice(domain.lastIndexOf('.') + 1);
|
||
if (BLOCKED_TLDS.has(tld)) {
|
||
throw new ApiError(400, `Top-Level-Domain "${tld}" ist nicht erlaubt (reservierte/private TLD).`);
|
||
}
|
||
return trimmed.toLowerCase();
|
||
}
|
||
|
||
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
|
||
const where: Record<string, unknown> = { customerId };
|
||
if (!includeInactive) {
|
||
where.isActive = true;
|
||
}
|
||
return prisma.stressfreiEmail.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
}
|
||
|
||
// Mit Mailbox-Status für E-Mail-Client
|
||
export async function getEmailsWithMailboxByCustomerId(customerId: number) {
|
||
return prisma.stressfreiEmail.findMany({
|
||
where: {
|
||
customerId,
|
||
isActive: true,
|
||
hasMailbox: true,
|
||
},
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
notes: true,
|
||
hasMailbox: true,
|
||
_count: {
|
||
select: {
|
||
cachedEmails: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: { email: 'asc' },
|
||
});
|
||
}
|
||
|
||
export async function getEmailById(id: number) {
|
||
return prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
});
|
||
}
|
||
|
||
// E-Mail mit Mailbox-Status laden
|
||
export async function getEmailWithMailboxById(id: number) {
|
||
return prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: {
|
||
id: true,
|
||
customerId: true,
|
||
email: true,
|
||
platform: true,
|
||
notes: true,
|
||
isActive: true,
|
||
hasMailbox: true,
|
||
emailPasswordEncrypted: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
export interface CreateEmailData {
|
||
customerId: number;
|
||
email: string;
|
||
platform?: string;
|
||
notes?: string;
|
||
provisionAtProvider?: boolean;
|
||
createMailbox?: boolean;
|
||
}
|
||
|
||
export async function createEmail(data: CreateEmailData) {
|
||
const { provisionAtProvider, createMailbox, ...emailData } = data;
|
||
|
||
// Duplikatscheck: pro Kunde darf eine Stressfrei-Adresse nur einmal
|
||
// existieren. Case-insensitive, weil RFC-localpart-Großschreibung in
|
||
// der Praxis nie semantischen Unterschied macht und der Provider eh
|
||
// einheitlich lowercased.
|
||
const normalized = data.email.trim().toLowerCase();
|
||
const conflict = await prisma.stressfreiEmail.findFirst({
|
||
where: {
|
||
customerId: data.customerId,
|
||
email: { equals: normalized },
|
||
},
|
||
select: { id: true, isActive: true },
|
||
});
|
||
if (conflict) {
|
||
const hint = conflict.isActive
|
||
? 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.'
|
||
: 'Diese E-Mail-Adresse existiert bei diesem Kunden bereits (deaktiviert). Bitte reaktiviere den vorhandenen Eintrag, statt einen neuen anzulegen.';
|
||
throw new ApiError(409, hint);
|
||
}
|
||
// Wert in DB ist eh schon lowercase – wir setzen es einheitlich.
|
||
emailData.email = normalized;
|
||
|
||
// Falls beim Provider anlegen gewünscht
|
||
if (provisionAtProvider) {
|
||
// Kunde laden für Weiterleitung
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: data.customerId },
|
||
select: { email: true },
|
||
});
|
||
|
||
if (!customer?.email) {
|
||
throw new Error('Kunde hat keine E-Mail-Adresse für Weiterleitung');
|
||
}
|
||
|
||
// LocalPart extrahieren
|
||
const localPart = data.email.split('@')[0];
|
||
|
||
if (createMailbox) {
|
||
// Mit echter Mailbox anlegen
|
||
const password = generateSecurePassword();
|
||
const result = await provisionEmailWithMailbox(localPart, customer.email, password);
|
||
|
||
if (!result.success) {
|
||
throw new Error(result.error || 'Fehler beim Anlegen der Mailbox');
|
||
}
|
||
|
||
// Passwort verschlüsseln und speichern
|
||
const passwordEncrypted = encrypt(password);
|
||
|
||
return prisma.stressfreiEmail.create({
|
||
data: {
|
||
...emailData,
|
||
isActive: true,
|
||
hasMailbox: true,
|
||
isProvisioned: true,
|
||
provisionedAt: new Date(),
|
||
emailPasswordEncrypted: passwordEncrypted,
|
||
},
|
||
});
|
||
} else {
|
||
// Nur Weiterleitung anlegen
|
||
const result = await provisionEmail(localPart, customer.email);
|
||
|
||
if (!result.success && !result.message?.includes('existiert bereits')) {
|
||
throw new Error(result.error || 'Fehler beim Anlegen der E-Mail');
|
||
}
|
||
}
|
||
}
|
||
|
||
return prisma.stressfreiEmail.create({
|
||
data: {
|
||
...emailData,
|
||
isActive: true,
|
||
hasMailbox: createMailbox || false,
|
||
// Provisioned-Flag nur setzen wenn Provider-Aufruf gerade lief (oder
|
||
// die Mail bei Plesk schon existierte und der „existiert bereits"-Pfad
|
||
// gegriffen hat).
|
||
isProvisioned: !!provisionAtProvider,
|
||
provisionedAt: provisionAtProvider ? new Date() : null,
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function updateEmail(
|
||
id: number,
|
||
data: {
|
||
email?: string;
|
||
platform?: string;
|
||
notes?: string;
|
||
isActive?: boolean;
|
||
}
|
||
) {
|
||
// Beim Umbenennen der E-Mail-Adresse erneut Kollisionscheck. Sonst
|
||
// kann man eine zweite Adresse mit identischer Mail beim selben Kunden
|
||
// anlegen (Umweg um den Create-Check).
|
||
if (typeof data.email === 'string' && data.email.trim() !== '') {
|
||
const normalized = data.email.trim().toLowerCase();
|
||
const current = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: { customerId: true, email: true },
|
||
});
|
||
if (!current) {
|
||
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
|
||
}
|
||
if (normalized !== current.email.toLowerCase()) {
|
||
const conflict = await prisma.stressfreiEmail.findFirst({
|
||
where: {
|
||
customerId: current.customerId,
|
||
email: { equals: normalized },
|
||
NOT: { id },
|
||
},
|
||
select: { id: true },
|
||
});
|
||
if (conflict) {
|
||
throw new ApiError(409, 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.');
|
||
}
|
||
}
|
||
data.email = normalized;
|
||
}
|
||
|
||
return prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data,
|
||
});
|
||
}
|
||
|
||
export async function deleteEmail(id: number) {
|
||
return prisma.stressfreiEmail.delete({ where: { id } });
|
||
}
|
||
|
||
/**
|
||
* Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und
|
||
* direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical
|
||
* Liste – das Sub-Modal arbeitet auf Snapshot-Basis.
|
||
*
|
||
* Pentest 71.2: Dedup über `canonicalEmailKey` (Plus-Tags strippen),
|
||
* damit `billing+tag@x.de` und `billing@x.de` als gleiches Ziel
|
||
* erkannt werden – auch im Vergleich zur Stamm-E-Mail des Kunden.
|
||
*
|
||
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
|
||
* damit Plesk und DB nicht auseinanderlaufen.
|
||
*
|
||
* Pentest 81.1: Self-Forward wird hart abgelehnt – würde sonst am
|
||
* Provider einen Mail-Loop erzeugen (Stressfrei-Adresse leitet auf
|
||
* sich selbst um → unendliche Weiterleitung).
|
||
*/
|
||
export async function setAdditionalForwards(
|
||
id: number,
|
||
emails: string[],
|
||
): Promise<{ success: boolean; forwardTargets?: string[]; error?: string }> {
|
||
// Kunden-Stamm-Mail + eigene Email holen für Dedup gegen die fest
|
||
// gesetzten Ziele bzw. die Stressfrei-Adresse selbst.
|
||
const meta = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: {
|
||
email: true,
|
||
additionalForwardingEmails: true,
|
||
customer: { select: { email: true } },
|
||
},
|
||
});
|
||
if (!meta) {
|
||
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
|
||
}
|
||
const previousRaw = meta.additionalForwardingEmails;
|
||
const customerEmailKey = meta.customer?.email
|
||
? canonicalEmailKey(meta.customer.email)
|
||
: null;
|
||
const selfKey = canonicalEmailKey(meta.email);
|
||
|
||
// Input normalisieren + Duplikate raus.
|
||
const seen = new Set<string>();
|
||
if (customerEmailKey) seen.add(customerEmailKey);
|
||
const cleaned: string[] = [];
|
||
for (const raw of emails) {
|
||
const ok = assertValidForwardingEmail(raw);
|
||
const key = canonicalEmailKey(ok);
|
||
// 81.1: Eintrag, der auf die Adresse selbst zeigt, würde einen
|
||
// Mail-Loop am Provider erzeugen. Hart ablehnen mit klarer
|
||
// Fehlermeldung, statt silent zu droppen – der User soll merken,
|
||
// dass sein Eintrag bewusst nicht akzeptiert wurde.
|
||
if (key === selfKey) {
|
||
throw new ApiError(
|
||
400,
|
||
`"${ok}" zeigt auf die Adresse selbst – Mail-Loop. Bitte eine andere Weiterleitungsadresse wählen.`,
|
||
);
|
||
}
|
||
if (!seen.has(key)) {
|
||
seen.add(key);
|
||
cleaned.push(ok);
|
||
}
|
||
}
|
||
|
||
const nextRaw = serializeAdditionalForwards(cleaned);
|
||
await prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data: { additionalForwardingEmails: nextRaw },
|
||
});
|
||
|
||
// Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto
|
||
// mit der alten Liste weiter. autoImport=false, weil unsere DB-Liste
|
||
// hier die explizite User-Intent ist – kein Plesk-Member-Auto-Pull,
|
||
// sonst landen gerade entfernte Adressen zurück in der Liste.
|
||
const syncResult = await syncForwardingForEmail(id, { autoImportPleskMembers: false });
|
||
|
||
// 71.4: Rollback wenn Plesk den Sync abgelehnt hat. DB darf nicht
|
||
// den optimistischen Stand zeigen, wenn der Provider noch auf dem
|
||
// alten Stand ist.
|
||
if (!syncResult.success && previousRaw !== nextRaw) {
|
||
await prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data: { additionalForwardingEmails: previousRaw },
|
||
}).catch((rollbackErr) => {
|
||
console.error(
|
||
'[setAdditionalForwards] Rollback nach Provider-Fail fehlgeschlagen:',
|
||
rollbackErr,
|
||
);
|
||
});
|
||
}
|
||
|
||
return syncResult;
|
||
}
|
||
|
||
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
||
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
});
|
||
|
||
if (!stressfreiEmail) {
|
||
return { success: false, error: 'StressfreiEmail nicht gefunden' };
|
||
}
|
||
|
||
if (stressfreiEmail.hasMailbox) {
|
||
return { success: false, error: 'Mailbox ist bereits aktiviert' };
|
||
}
|
||
|
||
const localPart = stressfreiEmail.email.split('@')[0];
|
||
const password = generateSecurePassword();
|
||
|
||
// Mailbox für existierende E-Mail aktivieren (nicht neu erstellen!)
|
||
const result = await enableMailboxForExistingEmail(localPart, password);
|
||
|
||
if (!result.success) {
|
||
return { success: false, error: result.error || 'Fehler beim Aktivieren der Mailbox' };
|
||
}
|
||
|
||
// Passwort verschlüsseln und speichern
|
||
const passwordEncrypted = encrypt(password);
|
||
|
||
await prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data: {
|
||
hasMailbox: true,
|
||
emailPasswordEncrypted: passwordEncrypted,
|
||
},
|
||
});
|
||
|
||
return { success: true };
|
||
}
|
||
|
||
// Mailbox-Status mit Provider synchronisieren
|
||
export async function syncMailboxStatus(id: number): Promise<{
|
||
success: boolean;
|
||
hasMailbox?: boolean;
|
||
wasUpdated?: boolean;
|
||
error?: string
|
||
}> {
|
||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: { email: true, hasMailbox: true, isProvisioned: true, provisionedAt: true },
|
||
});
|
||
|
||
if (!stressfreiEmail) {
|
||
return { success: false, error: 'StressfreiEmail nicht gefunden' };
|
||
}
|
||
|
||
const localPart = stressfreiEmail.email.split('@')[0];
|
||
|
||
// Provider-Status prüfen
|
||
const providerStatus = await checkEmailExists(localPart);
|
||
|
||
// Self-Healing für `isProvisioned`: das Flag wurde in einer früheren Code-
|
||
// Version beim Provisioning nie gesetzt → DB ist stellenweise inkonsistent
|
||
// zum Provider. Wir reconciliieren bei jedem Status-Sync mit.
|
||
const updates: Record<string, unknown> = {};
|
||
|
||
if (!providerStatus.exists) {
|
||
// Beim Provider nicht (mehr) vorhanden → DB-Flag entsprechend
|
||
if (stressfreiEmail.isProvisioned) {
|
||
updates.isProvisioned = false;
|
||
}
|
||
if (stressfreiEmail.hasMailbox) {
|
||
updates.hasMailbox = false;
|
||
}
|
||
if (Object.keys(updates).length > 0) {
|
||
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
|
||
return { success: true, hasMailbox: false, wasUpdated: true };
|
||
}
|
||
return { success: true, hasMailbox: false, wasUpdated: false };
|
||
}
|
||
|
||
// Beim Provider vorhanden → isProvisioned auf true ziehen falls noch nicht
|
||
if (!stressfreiEmail.isProvisioned) {
|
||
updates.isProvisioned = true;
|
||
if (!stressfreiEmail.provisionedAt) {
|
||
updates.provisionedAt = new Date();
|
||
}
|
||
}
|
||
|
||
const providerHasMailbox = providerStatus.hasMailbox === true;
|
||
if (stressfreiEmail.hasMailbox !== providerHasMailbox) {
|
||
updates.hasMailbox = providerHasMailbox;
|
||
}
|
||
|
||
if (Object.keys(updates).length > 0) {
|
||
await prisma.stressfreiEmail.update({ where: { id }, data: updates });
|
||
console.log(`Stressfrei-Status für ${stressfreiEmail.email} reconciled:`, updates);
|
||
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: true };
|
||
}
|
||
|
||
return { success: true, hasMailbox: providerHasMailbox, wasUpdated: false };
|
||
}
|
||
|
||
// Passwort für IMAP/SMTP-Zugang entschlüsseln (nur für autorisierte Nutzung)
|
||
export async function getDecryptedPassword(id: number): Promise<string | null> {
|
||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: { emailPasswordEncrypted: true },
|
||
});
|
||
|
||
if (!stressfreiEmail?.emailPasswordEncrypted) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||
} catch {
|
||
console.error('Fehler beim Entschlüsseln des Passworts');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Weiterleitungen einer Stressfrei-Adresse neu setzen (z.B. nach Änderung der
|
||
// Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch
|
||
// [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config].
|
||
//
|
||
// Wenn die Adresse `hasMailbox` ist: setzt zusätzlich das im CRM verschlüsselt
|
||
// hinterlegte Passwort am Provider neu (Use-Case: Plesk-Restore, manueller
|
||
// Eingriff im Plesk-UI etc. – CRM und Provider können sich entkoppeln, sodass
|
||
// IMAP/SMTP-Logins im CRM nicht mehr passen). Self-Healing.
|
||
//
|
||
// Idempotent: das Plesk-CLI `set:` überschreibt die Adressliste komplett, kein
|
||
// Duplikat-Risiko bei Mehrfachaufruf. Wenn die Operation erfolgreich war wird
|
||
// das `isProvisioned`-Flag automatisch auf `true` gezogen (historische
|
||
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
||
export async function syncForwardingForEmail(
|
||
id: number,
|
||
options: { autoImportPleskMembers?: boolean } = {},
|
||
): Promise<{
|
||
success: boolean;
|
||
forwardTargets?: string[];
|
||
customerEmail?: string;
|
||
passwordReset?: boolean;
|
||
error?: string;
|
||
}> {
|
||
// Auto-Import übernimmt unbekannte Plesk-Members in unsere DB. Macht
|
||
// beim Sync-Button Sinn (Bestands-Migration), aber NICHT beim
|
||
// User-getriggerten Add/Remove – dort ist die DB-Liste die Wahrheit.
|
||
// Sonst kreisen entfernte Adressen zurück in die Liste:
|
||
// 1. User entfernt c → DB=[a,b], Plesk=[a,b,c]
|
||
// 2. Auto-Import: "c ist in Plesk aber nicht in DB → in DB schreiben"
|
||
// 3. → DB=[a,b,c], Diff sagt nichts zu löschen, Plesk bleibt [a,b,c].
|
||
const autoImport = options.autoImportPleskMembers ?? true;
|
||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: {
|
||
email: true,
|
||
customerId: true,
|
||
isProvisioned: true,
|
||
hasMailbox: true,
|
||
emailPasswordEncrypted: true,
|
||
additionalForwardingEmails: true,
|
||
},
|
||
});
|
||
|
||
if (!stressfreiEmail) {
|
||
return { success: false, error: 'StressfreiEmail nicht gefunden' };
|
||
}
|
||
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: stressfreiEmail.customerId },
|
||
select: { email: true },
|
||
});
|
||
if (!customer?.email) {
|
||
return { success: false, error: 'Kunde hat keine Stamm-E-Mail-Adresse hinterlegt' };
|
||
}
|
||
|
||
const config = await getActiveProviderConfig();
|
||
const forwardTargets: string[] = [customer.email];
|
||
if (config?.defaultForwardEmail) {
|
||
forwardTargets.push(config.defaultForwardEmail);
|
||
}
|
||
// Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate
|
||
// gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit
|
||
// Plesk nicht mit Wiederholungen die Liste aufbläht. Pentest 71.2:
|
||
// Vergleich über `canonicalEmailKey`, damit Plus-Tags nicht doppelt
|
||
// zustellen.
|
||
const seenKeys = new Set(forwardTargets.map(canonicalEmailKey));
|
||
for (const extra of parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails)) {
|
||
const key = canonicalEmailKey(extra);
|
||
if (!seenKeys.has(key)) {
|
||
seenKeys.add(key);
|
||
forwardTargets.push(extra);
|
||
}
|
||
}
|
||
|
||
const localPart = stressfreiEmail.email.split('@')[0];
|
||
|
||
// 0) Auto-Migration: Plesk hat zwei Verteil-Mechanismen (Mailgroup +
|
||
// Forwarding). Alt-Anlagen liefen oft via Mailgroup – unser Sync
|
||
// schreibt aber nur in die Forwarding-Liste, daher landeten neue
|
||
// Adressen nirgendwo. Hier holen wir die aktuellen Mailgroup-Members
|
||
// ab und ziehen alle, die wir nicht schon kennen, in unsere
|
||
// additionalForwardingEmails-Liste rein. Der nachfolgende Plesk-Call
|
||
// deaktiviert dann die Mailgroup und schreibt die volle Liste als
|
||
// Forwarding. Verlustfrei – kein Empfänger fällt raus.
|
||
// Pentest 83.2: Self-Forward auch beim Import blocken. Die
|
||
// Stressfrei-Adresse selbst darf nicht aus Plesk in unsere DB
|
||
// landen – sonst läuft sie nach dem Mailgroup→Forwarding-Umschalten
|
||
// als Forwarding-Target auf sich selbst (Mail-Loop).
|
||
seenKeys.add(canonicalEmailKey(stressfreiEmail.email));
|
||
|
||
if (autoImport) {
|
||
try {
|
||
const pleskState = await checkEmailExists(localPart);
|
||
const existingMembers = [
|
||
...(pleskState.mailgroupMembers ?? []),
|
||
...(pleskState.forwardingTargets ?? []),
|
||
];
|
||
const newImports: string[] = [];
|
||
for (const member of existingMembers) {
|
||
// Pentest 83.1: importierte Adressen aus Plesk müssen denselben
|
||
// Filter passieren wie User-Eingaben (TLD-Blocklist, Format).
|
||
// Sonst rutschen reservierte TLDs wie `.internal` ohne Check
|
||
// in unsere DB, falls ein Plesk-Admin sie dort manuell gepflegt
|
||
// hat. Ungültige werden silent gedroppt – Log informiert.
|
||
let validated: string;
|
||
try {
|
||
validated = assertValidForwardingEmail(member);
|
||
} catch (validationErr) {
|
||
const reason = validationErr instanceof Error ? validationErr.message : 'unbekannt';
|
||
console.debug(
|
||
`[syncForwardingForEmail] Plesk-Member "${member}" verworfen: ${reason}`,
|
||
);
|
||
continue;
|
||
}
|
||
const key = canonicalEmailKey(validated);
|
||
if (!seenKeys.has(key)) {
|
||
seenKeys.add(key);
|
||
forwardTargets.push(validated);
|
||
newImports.push(validated);
|
||
}
|
||
}
|
||
if (newImports.length > 0) {
|
||
const mergedAdditional = [
|
||
...parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails),
|
||
...newImports,
|
||
];
|
||
await prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data: { additionalForwardingEmails: serializeAdditionalForwards(mergedAdditional) },
|
||
});
|
||
// Pentest 83.3: PII-Logs auf debug-Level statt log-Level.
|
||
console.debug(
|
||
`[syncForwardingForEmail] Importiert aus Plesk-Mailgroup für ${stressfreiEmail.email}:`,
|
||
newImports,
|
||
);
|
||
}
|
||
} catch (importErr) {
|
||
// Nicht hart fehlschlagen – im schlimmsten Fall fehlen ein paar
|
||
// alte Empfänger, aber der eigentliche Sync soll trotzdem laufen.
|
||
console.error('[syncForwardingForEmail] Mailgroup-Import fehlgeschlagen:', importErr);
|
||
}
|
||
}
|
||
|
||
// 1) Forwards neu setzen (deaktiviert intern Mailgroup).
|
||
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
||
if (!forwardResult.success) {
|
||
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
||
// statt der rohen Provider-Nachricht.
|
||
const err = forwardResult.error || 'Provider-Update fehlgeschlagen';
|
||
const friendly = /not\s*found|nicht\s*gefunden/i.test(err)
|
||
? 'E-Mail-Adresse beim Provider nicht gefunden – wurde sie dort gelöscht?'
|
||
: err;
|
||
return { success: false, error: friendly };
|
||
}
|
||
|
||
// 2) Wenn Mailbox: Passwort aus CRM-Speicher entschlüsseln und am Provider
|
||
// neu setzen (Self-Healing nach Provider-seitigen Änderungen).
|
||
let passwordReset = false;
|
||
if (stressfreiEmail.hasMailbox && stressfreiEmail.emailPasswordEncrypted) {
|
||
try {
|
||
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||
const pwResult = await updateMailboxPassword(localPart, password);
|
||
if (!pwResult.success) {
|
||
// Forwards waren schon erfolgreich – wir geben Forward-Erfolg + Passwort-
|
||
// Fehler kombiniert zurück, statt die ganze Operation rot zu machen.
|
||
return {
|
||
success: false,
|
||
forwardTargets,
|
||
customerEmail: customer.email,
|
||
error:
|
||
'Weiterleitungen aktualisiert, aber Passwort-Sync fehlgeschlagen: ' +
|
||
(pwResult.error || 'unbekannt'),
|
||
};
|
||
}
|
||
passwordReset = true;
|
||
} catch (e) {
|
||
return {
|
||
success: false,
|
||
forwardTargets,
|
||
customerEmail: customer.email,
|
||
error:
|
||
'Weiterleitungen aktualisiert, aber Passwort konnte nicht entschlüsselt werden – ' +
|
||
'evtl. wurde der ENCRYPTION_KEY rotiert',
|
||
};
|
||
}
|
||
}
|
||
|
||
// 3) Self-Healing: nach erfolgreichem Provider-Aufruf wissen wir definitiv,
|
||
// dass die Adresse beim Provider existiert → Flag korrigieren.
|
||
if (!stressfreiEmail.isProvisioned) {
|
||
await prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data: { isProvisioned: true, provisionedAt: new Date() },
|
||
});
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
forwardTargets,
|
||
customerEmail: customer.email,
|
||
passwordReset,
|
||
};
|
||
}
|
||
|
||
// Passwort neu generieren und beim Provider setzen
|
||
export async function resetMailboxPassword(id: number): Promise<{ success: boolean; password?: string; error?: string }> {
|
||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: { email: true, hasMailbox: true },
|
||
});
|
||
|
||
if (!stressfreiEmail) {
|
||
return { success: false, error: 'StressfreiEmail nicht gefunden' };
|
||
}
|
||
|
||
if (!stressfreiEmail.hasMailbox) {
|
||
return { success: false, error: 'Keine Mailbox für diese E-Mail-Adresse' };
|
||
}
|
||
|
||
// Neues Passwort generieren
|
||
const newPassword = generateSecurePassword();
|
||
const localPart = stressfreiEmail.email.split('@')[0];
|
||
|
||
// Passwort beim Provider ändern
|
||
const providerResult = await updateMailboxPassword(localPart, newPassword);
|
||
if (!providerResult.success) {
|
||
return { success: false, error: providerResult.error || 'Fehler beim Aktualisieren des Passworts beim Provider' };
|
||
}
|
||
|
||
// Passwort verschlüsseln und lokal speichern
|
||
const passwordEncrypted = encrypt(newPassword);
|
||
|
||
await prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data: { emailPasswordEncrypted: passwordEncrypted },
|
||
});
|
||
|
||
return { success: true, password: newPassword };
|
||
}
|