51eb12b414
User-Feedback: der Refresh-Button war auch bei nicht-provisionierten Adressen sichtbar (die nur als DB-Eintrag ohne Plesk-Pendant existieren). Klick darauf gab korrekt einen Fehler, war aber unschön. Bedingung wieder auf `emailItem.isProvisioned` einschränken. Für historische Einträge, bei denen das Flag wegen des alten Bugs nie gesetzt wurde, gibt es jetzt einen automatischen Reconcile-Pfad: `syncMailboxStatus` (wird beim Öffnen jedes Edit-Modals aufgerufen) prüft nicht mehr nur `hasMailbox`, sondern auch `isProvisioned`: - Provider antwortet "existiert" + DB sagt isProvisioned=false → DB-Flag auf true ziehen + provisionedAt setzen - Provider antwortet "nicht da" + DB sagt isProvisioned=true → DB-Flag auf false (Adresse wurde im Plesk-UI manuell gelöscht) - hasMailbox wird zusätzlich konsistent gehalten Damit heilen sich falsch markierte Adressen automatisch, sobald der User sie einmal aufmacht zum Bearbeiten – der Refresh-Button erscheint dann beim Re-Open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
435 lines
13 KiB
TypeScript
435 lines
13 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';
|
||
|
||
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;
|
||
|
||
// 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;
|
||
}
|
||
) {
|
||
return prisma.stressfreiEmail.update({
|
||
where: { id },
|
||
data,
|
||
});
|
||
}
|
||
|
||
export async function deleteEmail(id: number) {
|
||
return prisma.stressfreiEmail.delete({ where: { id } });
|
||
}
|
||
|
||
// 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,
|
||
): Promise<{
|
||
success: boolean;
|
||
forwardTargets?: string[];
|
||
customerEmail?: string;
|
||
passwordReset?: boolean;
|
||
error?: string;
|
||
}> {
|
||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||
where: { id },
|
||
select: {
|
||
email: true,
|
||
customerId: true,
|
||
isProvisioned: true,
|
||
hasMailbox: true,
|
||
emailPasswordEncrypted: 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);
|
||
}
|
||
|
||
const localPart = stressfreiEmail.email.split('@')[0];
|
||
|
||
// 1) Forwards neu setzen.
|
||
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 };
|
||
}
|