// ==================== CACHED EMAIL SERVICE ==================== // Service für E-Mail-Caching und Vertragszuordnung import { PrismaClient, CachedEmail, Prisma, EmailFolder } from '@prisma/client'; import { decrypt } from '../utils/encryption.js'; import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete } from './imapService.js'; import { getImapSmtpSettings } from './emailProvider/emailProviderService.js'; const prisma = new PrismaClient(); // ==================== TYPES ==================== export interface CachedEmailWithRelations extends CachedEmail { stressfreiEmail?: { id: number; email: string; customerId: number; }; contract?: { id: number; contractNumber: string; } | null; } // Parameter für gesendete E-Mail export interface SentEmailParams { to: string[]; cc?: string[]; subject: string; text?: string; html?: string; messageId: string; contractId?: number; // Optional: Vertrag dem die E-Mail zugeordnet wird attachmentNames?: string[]; // Namen der Anhänge uid?: number; // UID im IMAP Sent-Ordner (für Attachment-Download) } export interface SyncResult { success: boolean; newEmails: number; totalEmails: number; error?: string; } export interface EmailListOptions { stressfreiEmailId?: number; customerId?: number; contractId?: number; folder?: string; limit?: number; offset?: number; includeBody?: boolean; } // ==================== SYNC FUNCTIONS ==================== // E-Mails für eine StressfreiEmail synchronisieren export async function syncEmailsForAccount( stressfreiEmailId: number, options?: { folder?: string; fullSync?: boolean } ): Promise { const folder = options?.folder || 'INBOX'; const fullSync = options?.fullSync || false; try { // StressfreiEmail mit Mailbox-Daten laden const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ where: { id: stressfreiEmailId }, }); if (!stressfreiEmail) { return { success: false, newEmails: 0, totalEmails: 0, error: 'StressfreiEmail nicht gefunden' }; } if (!stressfreiEmail.hasMailbox || !stressfreiEmail.emailPasswordEncrypted) { return { success: false, newEmails: 0, totalEmails: 0, error: 'Keine Mailbox für diese E-Mail-Adresse' }; } // IMAP/SMTP-Einstellungen vom Provider holen const settings = await getImapSmtpSettings(); if (!settings) { return { success: false, newEmails: 0, totalEmails: 0, error: 'Keine E-Mail-Provider-Einstellungen gefunden' }; } // Passwort entschlüsseln let password: string; try { password = decrypt(stressfreiEmail.emailPasswordEncrypted); } catch { return { success: false, newEmails: 0, totalEmails: 0, error: 'Passwort konnte nicht entschlüsselt werden' }; } // IMAP-Credentials zusammenstellen const credentials: ImapCredentials = { host: settings.imapServer, port: settings.imapPort, user: stressfreiEmail.email, password, encryption: settings.imapEncryption, allowSelfSignedCerts: settings.allowSelfSignedCerts, }; // Folder-Mapping: IMAP-Ordner zu DB-Ordner const dbFolder = folder.toUpperCase() === 'SENT' || folder.toLowerCase() === 'sent' ? EmailFolder.SENT : EmailFolder.INBOX; // Für inkrementellen Sync: Höchste UID des Ordners ermitteln let sinceUid: number | undefined; if (!fullSync) { const lastEmail = await prisma.cachedEmail.findFirst({ where: { stressfreiEmailId, folder: dbFolder }, orderBy: { uid: 'desc' }, select: { uid: true }, }); if (lastEmail) { sinceUid = lastEmail.uid; } } // E-Mails vom IMAP-Server abrufen const fetchedEmails = await fetchEmails(credentials, { folder, sinceUid, limit: fullSync ? 500 : 100, // Mehr bei Full-Sync }); // Neue E-Mails in DB speichern let newCount = 0; for (const email of fetchedEmails) { const created = await upsertCachedEmail(stressfreiEmailId, email, dbFolder); if (created) newCount++; } // Gesamtzahl ermitteln const totalEmails = await prisma.cachedEmail.count({ where: { stressfreiEmailId }, }); return { success: true, newEmails: newCount, totalEmails, }; } catch (error) { console.error('syncEmailsForAccount error:', error); const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; return { success: false, newEmails: 0, totalEmails: 0, error: errorMessage, }; } } // Alle Ordner synchronisieren (INBOX + SENT) export async function syncAllFoldersForAccount( stressfreiEmailId: number, options?: { fullSync?: boolean } ): Promise { const fullSync = options?.fullSync || false; // INBOX synchronisieren const inboxResult = await syncEmailsForAccount(stressfreiEmailId, { folder: 'INBOX', fullSync }); // SENT synchronisieren (Plesk verwendet "Sent" als Ordnername) const sentResult = await syncEmailsForAccount(stressfreiEmailId, { folder: 'Sent', fullSync }); // Ergebnisse kombinieren if (!inboxResult.success && !sentResult.success) { return { success: false, newEmails: 0, totalEmails: 0, error: inboxResult.error || sentResult.error || 'Synchronisation fehlgeschlagen', }; } return { success: true, newEmails: inboxResult.newEmails + sentResult.newEmails, totalEmails: inboxResult.totalEmails, }; } // Einzelne E-Mail in DB speichern/aktualisieren async function upsertCachedEmail( stressfreiEmailId: number, email: FetchedEmail, folder: EmailFolder = EmailFolder.INBOX ): Promise { try { // Prüfen ob bereits vorhanden (via messageId + folder) // WICHTIG: Gleiche messageId kann in INBOX und SENT existieren (z.B. E-Mail an sich selbst) const existing = await prisma.cachedEmail.findUnique({ where: { stressfreiEmailId_messageId_folder: { stressfreiEmailId, messageId: email.messageId, folder, }, }, }); if (existing) { // Bereits vorhanden return false; } // Neue E-Mail anlegen await prisma.cachedEmail.create({ data: { stressfreiEmailId, folder, messageId: email.messageId, uid: email.uid, subject: email.subject, fromAddress: email.fromAddress, fromName: email.fromName, toAddresses: JSON.stringify(email.toAddresses), ccAddresses: email.ccAddresses.length > 0 ? JSON.stringify(email.ccAddresses) : null, receivedAt: email.date, textBody: email.textBody, htmlBody: email.htmlBody, hasAttachments: email.hasAttachments, attachmentNames: email.attachmentNames.length > 0 ? JSON.stringify(email.attachmentNames) : null, isRead: folder === EmailFolder.SENT, // Gesendete E-Mails sind bereits gelesen isStarred: false, }, }); return true; } catch (error) { // Duplikat-Fehler ignorieren (Race Condition) if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { return false; } throw error; } } // ==================== CRUD FUNCTIONS ==================== // E-Mails abrufen mit Optionen export async function getCachedEmails( options: EmailListOptions ): Promise { const where: Prisma.CachedEmailWhereInput = { isDeleted: false, // Gelöschte E-Mails ausschließen }; if (options.stressfreiEmailId) { where.stressfreiEmailId = options.stressfreiEmailId; } if (options.customerId) { where.stressfreiEmail = { customerId: options.customerId, }; } if (options.contractId) { where.contractId = options.contractId; } // Folder-Filter (INBOX oder SENT) if (options.folder === 'SENT') { where.folder = EmailFolder.SENT; } else if (options.folder === 'INBOX' || !options.folder) { // Standard: Nur Posteingang where.folder = EmailFolder.INBOX; } // Body-Felder nur wenn explizit angefordert (spart Bandbreite) const select: Prisma.CachedEmailSelect = { id: true, stressfreiEmailId: true, folder: true, messageId: true, uid: true, subject: true, fromAddress: true, fromName: true, toAddresses: true, ccAddresses: true, receivedAt: true, hasAttachments: true, attachmentNames: true, contractId: true, assignedAt: true, assignedBy: true, isAutoAssigned: true, isRead: true, isStarred: true, createdAt: true, updatedAt: true, stressfreiEmail: { select: { id: true, email: true, customerId: true, }, }, contract: { select: { id: true, contractNumber: true, }, }, }; if (options.includeBody) { select.textBody = true; select.htmlBody = true; } const emails = await prisma.cachedEmail.findMany({ where, select, orderBy: { receivedAt: 'desc' }, take: options.limit || 50, skip: options.offset || 0, }); return emails as CachedEmailWithRelations[]; } // Einzelne E-Mail abrufen (mit Body) export async function getCachedEmailById( id: number ): Promise { const email = await prisma.cachedEmail.findUnique({ where: { id }, include: { stressfreiEmail: { select: { id: true, email: true, customerId: true, }, }, contract: { select: { id: true, contractNumber: true, }, }, }, }); return email as CachedEmailWithRelations | null; } // E-Mail als gelesen markieren export async function markEmailAsRead(id: number): Promise { await prisma.cachedEmail.update({ where: { id }, data: { isRead: true }, }); } // E-Mail als ungelesen markieren export async function markEmailAsUnread(id: number): Promise { await prisma.cachedEmail.update({ where: { id }, data: { isRead: false }, }); } // E-Mail Stern setzen/entfernen export async function toggleEmailStar(id: number): Promise { const email = await prisma.cachedEmail.findUnique({ where: { id }, select: { isStarred: true }, }); if (!email) { throw new Error('E-Mail nicht gefunden'); } const newValue = !email.isStarred; await prisma.cachedEmail.update({ where: { id }, data: { isStarred: newValue }, }); return newValue; } // ==================== CONTRACT ASSIGNMENT ==================== // E-Mail einem Vertrag zuordnen export async function assignEmailToContract( emailId: number, contractId: number, userId?: number ): Promise { // Prüfen ob E-Mail existiert const email = await prisma.cachedEmail.findUnique({ where: { id: emailId }, }); if (!email) { throw new Error('E-Mail nicht gefunden'); } // Prüfen ob Vertrag existiert const contract = await prisma.contract.findUnique({ where: { id: contractId }, }); if (!contract) { throw new Error('Vertrag nicht gefunden'); } // Zuordnung setzen (manuell) const updated = await prisma.cachedEmail.update({ where: { id: emailId }, data: { contractId, assignedAt: new Date(), assignedBy: userId || null, isAutoAssigned: false, // Manuell zugeordnet }, include: { stressfreiEmail: { select: { id: true, email: true, customerId: true, }, }, contract: { select: { id: true, contractNumber: true, }, }, }, }); return updated as CachedEmailWithRelations; } // Vertragszuordnung aufheben export async function unassignEmailFromContract( emailId: number ): Promise { const updated = await prisma.cachedEmail.update({ where: { id: emailId }, data: { contractId: null, assignedAt: null, assignedBy: null, }, include: { stressfreiEmail: { select: { id: true, email: true, customerId: true, }, }, contract: { select: { id: true, contractNumber: true, }, }, }, }); return updated as CachedEmailWithRelations; } // ==================== HELPER FUNCTIONS ==================== // Anzahl ungelesener E-Mails für Kunde export async function getUnreadCountForCustomer(customerId: number): Promise { return prisma.cachedEmail.count({ where: { stressfreiEmail: { customerId, }, isRead: false, }, }); } // Anzahl ungelesener E-Mails für Vertrag export async function getUnreadCountForContract(contractId: number): Promise { return prisma.cachedEmail.count({ where: { contractId, isRead: false, }, }); } // E-Mail-Anzahl pro Ordner für ein Konto (total und ungelesen) export async function getFolderCountsForAccount(stressfreiEmailId: number): Promise<{ inbox: number; inboxUnread: number; sent: number; sentUnread: number; trash: number; trashUnread: number; }> { const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([ // INBOX total prisma.cachedEmail.count({ where: { stressfreiEmailId, folder: EmailFolder.INBOX, isDeleted: false }, }), // INBOX unread prisma.cachedEmail.count({ where: { stressfreiEmailId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false }, }), // SENT total prisma.cachedEmail.count({ where: { stressfreiEmailId, folder: EmailFolder.SENT, isDeleted: false }, }), // SENT unread prisma.cachedEmail.count({ where: { stressfreiEmailId, folder: EmailFolder.SENT, isDeleted: false, isRead: false }, }), // TRASH total (isDeleted = true) prisma.cachedEmail.count({ where: { stressfreiEmailId, isDeleted: true }, }), // TRASH unread prisma.cachedEmail.count({ where: { stressfreiEmailId, isDeleted: true, isRead: false }, }), ]); return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread }; } // E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails) export async function getFolderCountsForContract(contractId: number): Promise<{ inbox: number; inboxUnread: number; sent: number; sentUnread: number; }> { const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([ // INBOX total prisma.cachedEmail.count({ where: { contractId, folder: EmailFolder.INBOX, isDeleted: false }, }), // INBOX unread prisma.cachedEmail.count({ where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false }, }), // SENT total prisma.cachedEmail.count({ where: { contractId, folder: EmailFolder.SENT, isDeleted: false }, }), // SENT unread prisma.cachedEmail.count({ where: { contractId, folder: EmailFolder.SENT, isDeleted: false, isRead: false }, }), ]); return { inbox, inboxUnread, sent, sentUnread }; } // Alle StressfreiEmails eines Kunden mit Mailbox export async function getMailboxAccountsForCustomer(customerId: number) { return prisma.stressfreiEmail.findMany({ where: { customerId, hasMailbox: true, }, select: { id: true, email: true, notes: true, _count: { select: { cachedEmails: true, }, }, }, orderBy: { email: 'asc' }, }); } // E-Mail-Thread finden (basierend auf References/In-Reply-To) export async function getEmailThread(emailId: number): Promise { const email = await prisma.cachedEmail.findUnique({ where: { id: emailId }, }); if (!email) { return []; } // Suche nach E-Mails mit dem gleichen Betreff (vereinfachte Thread-Logik) // In einer vollständigen Implementierung würde man References/In-Reply-To parsen const baseSubject = email.subject?.replace(/^(Re|Fwd|Aw|Wg):\s*/gi, '') || ''; if (!baseSubject) { return [email as CachedEmailWithRelations]; } const thread = await prisma.cachedEmail.findMany({ where: { stressfreiEmailId: email.stressfreiEmailId, OR: [ { subject: baseSubject }, { subject: { startsWith: `Re: ${baseSubject}` } }, { subject: { startsWith: `Aw: ${baseSubject}` } }, { subject: { startsWith: `Fwd: ${baseSubject}` } }, { subject: { startsWith: `Wg: ${baseSubject}` } }, ], }, include: { stressfreiEmail: { select: { id: true, email: true, customerId: true, }, }, contract: { select: { id: true, contractNumber: true, }, }, }, orderBy: { receivedAt: 'asc' }, }); return thread as CachedEmailWithRelations[]; } // ==================== TRASH OPERATIONS ==================== export interface TrashOperationResult { success: boolean; error?: string; } // E-Mail in Papierkorb verschieben (markiert als gelöscht + IMAP-Move) export async function moveEmailToTrash(id: number): Promise { // E-Mail mit StressfreiEmail laden const email = await prisma.cachedEmail.findUnique({ where: { id }, include: { stressfreiEmail: true, }, }); if (!email) { return { success: false, error: 'E-Mail nicht gefunden' }; } if (email.isDeleted) { return { success: false, error: 'E-Mail ist bereits im Papierkorb' }; } // IMAP-Einstellungen und Credentials laden const settings = await getImapSmtpSettings(); if (!settings) { return { success: false, error: 'Keine E-Mail-Provider-Einstellungen' }; } if (!email.stressfreiEmail.emailPasswordEncrypted) { return { success: false, error: 'Keine Mailbox-Zugangsdaten' }; } let password: string; try { password = decrypt(email.stressfreiEmail.emailPasswordEncrypted); } catch { return { success: false, error: 'Passwort konnte nicht entschlüsselt werden' }; } const credentials: ImapCredentials = { host: settings.imapServer, port: settings.imapPort, user: email.stressfreiEmail.email, password, encryption: settings.imapEncryption, allowSelfSignedCerts: settings.allowSelfSignedCerts, }; // Ordner bestimmen (INBOX oder Sent) const sourceFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX'; // Auf IMAP-Server in Trash verschieben const imapResult = await moveToTrash(credentials, email.uid, sourceFolder); if (!imapResult.success) { return { success: false, error: imapResult.error || 'IMAP-Fehler beim Verschieben' }; } // In DB als gelöscht markieren (mit neuer UID im Trash) await prisma.cachedEmail.update({ where: { id }, data: { isDeleted: true, deletedAt: new Date(), uid: imapResult.newUid || email.uid, // Neue UID im Trash speichern }, }); return { success: true }; } // E-Mail aus Papierkorb wiederherstellen export async function restoreEmailFromTrash(id: number): Promise { // E-Mail laden const email = await prisma.cachedEmail.findUnique({ where: { id }, include: { stressfreiEmail: true, }, }); if (!email) { return { success: false, error: 'E-Mail nicht gefunden' }; } if (!email.isDeleted) { return { success: false, error: 'E-Mail ist nicht im Papierkorb' }; } // IMAP-Einstellungen und Credentials const settings = await getImapSmtpSettings(); if (!settings) { return { success: false, error: 'Keine E-Mail-Provider-Einstellungen' }; } if (!email.stressfreiEmail.emailPasswordEncrypted) { return { success: false, error: 'Keine Mailbox-Zugangsdaten' }; } let password: string; try { password = decrypt(email.stressfreiEmail.emailPasswordEncrypted); } catch { return { success: false, error: 'Passwort konnte nicht entschlüsselt werden' }; } const credentials: ImapCredentials = { host: settings.imapServer, port: settings.imapPort, user: email.stressfreiEmail.email, password, encryption: settings.imapEncryption, allowSelfSignedCerts: settings.allowSelfSignedCerts, }; // Ziel-Ordner bestimmen (basierend auf originalem Ordner) const targetFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX'; // Auf IMAP-Server aus Trash wiederherstellen const imapResult = await restoreFromTrash(credentials, email.uid, targetFolder); if (!imapResult.success) { return { success: false, error: imapResult.error || 'IMAP-Fehler beim Wiederherstellen' }; } // In DB als wiederhergestellt markieren await prisma.cachedEmail.update({ where: { id }, data: { isDeleted: false, deletedAt: null, uid: imapResult.newUid || email.uid, // Neue UID im wiederhergestellten Ordner }, }); return { success: true }; } // E-Mail endgültig löschen (aus Papierkorb) export async function permanentDeleteEmail(id: number): Promise { // E-Mail laden const email = await prisma.cachedEmail.findUnique({ where: { id }, include: { stressfreiEmail: true, }, }); if (!email) { return { success: false, error: 'E-Mail nicht gefunden' }; } if (!email.isDeleted) { return { success: false, error: 'E-Mail muss erst in den Papierkorb verschoben werden' }; } // IMAP-Einstellungen und Credentials const settings = await getImapSmtpSettings(); if (!settings) { return { success: false, error: 'Keine E-Mail-Provider-Einstellungen' }; } if (!email.stressfreiEmail.emailPasswordEncrypted) { return { success: false, error: 'Keine Mailbox-Zugangsdaten' }; } let password: string; try { password = decrypt(email.stressfreiEmail.emailPasswordEncrypted); } catch { return { success: false, error: 'Passwort konnte nicht entschlüsselt werden' }; } const credentials: ImapCredentials = { host: settings.imapServer, port: settings.imapPort, user: email.stressfreiEmail.email, password, encryption: settings.imapEncryption, allowSelfSignedCerts: settings.allowSelfSignedCerts, }; // Auf IMAP-Server endgültig löschen (aus Trash) const imapResult = await permanentDelete(credentials, email.uid); if (!imapResult.success) { return { success: false, error: imapResult.error || 'IMAP-Fehler beim endgültigen Löschen' }; } // Aus DB löschen await prisma.cachedEmail.delete({ where: { id }, }); return { success: true }; } // Papierkorb-E-Mails für einen Kunden abrufen export async function getTrashEmails(customerId: number): Promise { return prisma.cachedEmail.findMany({ where: { isDeleted: true, stressfreiEmail: { customerId, }, }, include: { stressfreiEmail: { select: { id: true, email: true, customerId: true, }, }, contract: { select: { id: true, contractNumber: true, }, }, }, orderBy: { deletedAt: 'desc' }, }) as Promise; } // Papierkorb-E-Mails zählen export async function getTrashCount(customerId: number): Promise { return prisma.cachedEmail.count({ where: { isDeleted: true, stressfreiEmail: { customerId, }, }, }); } // Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash) export async function deleteCachedEmail(id: number): Promise { // Zur Abwärtskompatibilität: In Papierkorb verschieben statt löschen const result = await moveEmailToTrash(id); if (!result.success) { throw new Error(result.error || 'Fehler beim Löschen'); } } // Alle gecachten E-Mails für eine StressfreiEmail löschen (nur Cache, kein IMAP) export async function clearCacheForAccount(stressfreiEmailId: number): Promise { const result = await prisma.cachedEmail.deleteMany({ where: { stressfreiEmailId }, }); return result.count; } // ==================== SENT EMAILS ==================== // Gesendete E-Mail speichern export async function createSentEmail( stressfreiEmailId: number, params: SentEmailParams ): Promise { // StressfreiEmail laden um Absender-Info zu bekommen const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ where: { id: stressfreiEmailId }, select: { email: true }, }); if (!stressfreiEmail) { throw new Error('StressfreiEmail nicht gefunden'); } const hasAttachments = params.attachmentNames && params.attachmentNames.length > 0; const email = await prisma.cachedEmail.create({ data: { stressfreiEmailId, folder: EmailFolder.SENT, messageId: params.messageId, uid: params.uid || 0, // UID vom IMAP Sent-Ordner (für Attachment-Download) subject: params.subject || null, fromAddress: stressfreiEmail.email, fromName: null, toAddresses: JSON.stringify(params.to), ccAddresses: params.cc && params.cc.length > 0 ? JSON.stringify(params.cc) : null, receivedAt: new Date(), // Sendezeitpunkt textBody: params.text || null, htmlBody: params.html || null, hasAttachments: hasAttachments || false, attachmentNames: hasAttachments ? JSON.stringify(params.attachmentNames) : null, isRead: true, // Gesendete sind immer "gelesen" isStarred: false, // Vertragszuordnung falls angegeben (automatisch = aus Vertrag gesendet) contractId: params.contractId || null, assignedAt: params.contractId ? new Date() : null, isAutoAssigned: params.contractId ? true : false, // Automatisch wenn aus Vertrag gesendet }, }); return email; } // Anzahl E-Mails für Konto nach Ordner export async function getEmailCountByFolder( stressfreiEmailId: number, folder: 'INBOX' | 'SENT' ): Promise { return prisma.cachedEmail.count({ where: { stressfreiEmailId, folder: folder === 'SENT' ? EmailFolder.SENT : EmailFolder.INBOX, }, }); }