Files
opencrm/backend/src/services/cachedEmail.service.ts
T
duffyduck 185b38dc55 feat(email): Suchleiste + erweiterte Filter im Email-Postfach
Variante B aus der Trade-off-Diskussion: Suchleiste über der Email-Liste
plus eine ausklappbare Box mit Detail-Filtern, alle AND-verknüpft.

Backend:
- EmailListOptions um search + 9 Detail-Filter erweitert (fromFilter,
  toFilter, subjectFilter, bodyFilter, attachmentNameFilter,
  hasAttachments, isRead, isStarred, receivedFrom, receivedTo)
- getCachedEmails baut die where-Klausel:
  * `search` → OR über Subject/From-Address/From-Name/Body (Volltext-
    Quicksearch)
  * Feldspezifische Filter werden AND-verknüpft an die where gehängt;
    From-/Body-Filter intern als kleine OR-Subqueries (Match in
    Adresse ODER Name; Match in textBody ODER htmlBody)
- Controller-Parser akzeptiert die Filter als Query-Parameter
  (parseBoolParam/parseDateParam tolerieren leere/invalide Werte)

Frontend:
- Suchleiste mit X-Button zum Leeren + Filter-Toggle mit Badge (zeigt
  Anzahl aktiver Filter)
- Ausklappbare Filter-Box: Von, An, Betreff, Inhalt, Datum von/bis,
  Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status
- Filter-State fließt via useMemo + queryKey in den useQuery → React
  Query macht automatisch ein Re-Fetch bei jeder Änderung
- "Alle zurücksetzen"-Button räumt komplett auf
- Nicht für TRASH-Folder eingeblendet (eigener Pfad ohne Filter-API)

Bewusst nicht gebaut: voller AND/OR-Builder mit Plus-Button und
Bool-Verschachtelung. Reale Such-Use-Cases im Email-Kontext sind
quasi immer AND-verknüpft; Bool-Builder bringt mehr Bedienprobleme
als Mehrwert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:31:43 +02:00

1022 lines
28 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.
// ==================== CACHED EMAIL SERVICE ====================
// Service für E-Mail-Caching und Vertragszuordnung
import { CachedEmail, Prisma, EmailFolder } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { decrypt } from '../utils/encryption.js';
import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete } from './imapService.js';
import { getImapSmtpSettings } from './emailProvider/emailProviderService.js';
// ==================== 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;
// Suche / Filter (alle AND-verknüpft)
search?: string; // Volltextsuche über subject + from + body
fromFilter?: string; // Absender enthält
toFilter?: string; // Empfänger enthält
subjectFilter?: string; // Subject enthält
bodyFilter?: string; // Body enthält (text/html)
attachmentNameFilter?: string; // Anhang-Dateiname enthält
hasAttachments?: boolean; // Nur mit/ohne Anhang
isRead?: boolean; // Gelesen-Status
isStarred?: boolean; // Markiert-Status
receivedFrom?: Date; // Empfangen ab
receivedTo?: Date; // Empfangen bis
}
// ==================== SYNC FUNCTIONS ====================
// E-Mails für eine StressfreiEmail synchronisieren
export async function syncEmailsForAccount(
stressfreiEmailId: number,
options?: { folder?: string; fullSync?: boolean }
): Promise<SyncResult> {
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<SyncResult> {
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<boolean> {
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<CachedEmailWithRelations[]> {
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;
}
// ===== Such-/Filter-Parameter =====
// Volltext-Quicksearch: durchsucht parallel Subject, From-Address/Name und
// Body. MariaDB `contains` ist case-insensitive bei utf8mb4_unicode_ci.
if (options.search && options.search.trim()) {
const q = options.search.trim();
where.OR = [
{ subject: { contains: q } },
{ fromAddress: { contains: q } },
{ fromName: { contains: q } },
{ textBody: { contains: q } },
];
}
// Feldspezifische Filter (alle AND-verknüpft mit dem Rest)
if (options.fromFilter?.trim()) {
const q = options.fromFilter.trim();
// Treffer in fromAddress ODER fromName für den Nutzer ist „Von" beides
where.AND = [
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
{ OR: [{ fromAddress: { contains: q } }, { fromName: { contains: q } }] },
];
}
if (options.toFilter?.trim()) {
where.toAddresses = { contains: options.toFilter.trim() };
}
if (options.subjectFilter?.trim()) {
where.subject = { contains: options.subjectFilter.trim() };
}
if (options.bodyFilter?.trim()) {
const q = options.bodyFilter.trim();
where.AND = [
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
{ OR: [{ textBody: { contains: q } }, { htmlBody: { contains: q } }] },
];
}
if (options.attachmentNameFilter?.trim()) {
where.attachmentNames = { contains: options.attachmentNameFilter.trim() };
}
if (typeof options.hasAttachments === 'boolean') {
where.hasAttachments = options.hasAttachments;
}
if (typeof options.isRead === 'boolean') {
where.isRead = options.isRead;
}
if (typeof options.isStarred === 'boolean') {
where.isStarred = options.isStarred;
}
if (options.receivedFrom || options.receivedTo) {
where.receivedAt = {};
if (options.receivedFrom) (where.receivedAt as Prisma.DateTimeFilter).gte = options.receivedFrom;
if (options.receivedTo) (where.receivedAt as Prisma.DateTimeFilter).lte = options.receivedTo;
}
// 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<CachedEmailWithRelations | null> {
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<void> {
await prisma.cachedEmail.update({
where: { id },
data: { isRead: true },
});
}
// E-Mail als ungelesen markieren
export async function markEmailAsUnread(id: number): Promise<void> {
await prisma.cachedEmail.update({
where: { id },
data: { isRead: false },
});
}
// E-Mail Stern setzen/entfernen
export async function toggleEmailStar(id: number): Promise<boolean> {
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<CachedEmailWithRelations> {
// 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<CachedEmailWithRelations> {
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<number> {
return prisma.cachedEmail.count({
where: {
stressfreiEmail: {
customerId,
},
isRead: false,
},
});
}
// Anzahl ungelesener E-Mails für Vertrag
export async function getUnreadCountForContract(contractId: number): Promise<number> {
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<CachedEmailWithRelations[]> {
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<TrashOperationResult> {
// 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<TrashOperationResult> {
// 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<TrashOperationResult> {
// 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<CachedEmailWithRelations[]> {
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<CachedEmailWithRelations[]>;
}
// Papierkorb-E-Mails zählen
export async function getTrashCount(customerId: number): Promise<number> {
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<void> {
// 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<number> {
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<CachedEmail> {
// 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<number> {
return prisma.cachedEmail.count({
where: {
stressfreiEmailId,
folder: folder === 'SENT' ? EmailFolder.SENT : EmailFolder.INBOX,
},
});
}