Files
opencrm/backend/src/services/stressfreiEmail.service.ts
T
duffyduck dfe2a4b241 Plesk-Sync: Auto-Import bei User-Remove deaktivieren
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.
2026-06-18 18:24:44 +02:00

735 lines
25 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.
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 };
}