993f2d10f0
Bug: Im Vertrags-Tab (Gesendet/Gelöscht) und im Kunden-Haupt-
Postfach (Gelöscht) wurden Mails aus ALLEN Postfächern angezeigt,
unabhängig vom ausgewählten Postfach. Im Vertrag fehlte zusätzlich
der Vertrags-Filter im Papierkorb.
Backend:
- getEmailsForContract akzeptiert accountId → stressfreiEmailId
- getTrashEmails (controller + service) nimmt {accountId, contractId}
- getFolderCountsForContract bekommt optional stressfreiEmailId und
zusätzlich trash/trashUnread im Result
Frontend:
- API-Client (getForContract/getTrash/getContractFolderCounts) nimmt
Filter entgegen
- ContractEmailsSection reicht selectedAccountId in alle drei Queries
+ queryKey durch. Trash-Badge kommt jetzt aus contract-scoped
Counts statt account-globalem stressfreiEmailApi
- EmailClientTab reicht selectedAccountId in die Trash-Query durch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1042 lines
30 KiB
TypeScript
1042 lines
30 KiB
TypeScript
// ==================== 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).
|
||
// Optional auf ein bestimmtes Postfach einschränken. Bug 2026-06-21:
|
||
// vorher zählten die Badges Mails aus ALLEN Postfächern, während die
|
||
// Liste (nach Fix) nur die des ausgewählten Postfachs zeigt – Badge
|
||
// und Liste liefen auseinander. Trash mit reingenommen, weil der
|
||
// Contract-Trash-Badge sonst wieder auf account-globalen Zähler
|
||
// zurückfallen müsste.
|
||
export async function getFolderCountsForContract(
|
||
contractId: number,
|
||
stressfreiEmailId?: number,
|
||
): Promise<{
|
||
inbox: number;
|
||
inboxUnread: number;
|
||
sent: number;
|
||
sentUnread: number;
|
||
trash: number;
|
||
trashUnread: number;
|
||
}> {
|
||
const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
|
||
if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
|
||
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
|
||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
|
||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
|
||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
|
||
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
|
||
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
|
||
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, isRead: false } }),
|
||
]);
|
||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||
}
|
||
|
||
// 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
|
||
// Optional: nach Postfach (stressfreiEmailId) und/oder Vertrag (contractId)
|
||
// einschränken. Vorher zeigte der Papierkorb immer ALLE gelöschten E-Mails
|
||
// des Kunden, unabhängig von welchem Postfach man gerade angemeldet ist –
|
||
// User-Bug 2026-06-21.
|
||
export async function getTrashEmails(
|
||
customerId: number,
|
||
options?: { stressfreiEmailId?: number; contractId?: number },
|
||
): Promise<CachedEmailWithRelations[]> {
|
||
const where: Prisma.CachedEmailWhereInput = {
|
||
isDeleted: true,
|
||
stressfreiEmail: { customerId },
|
||
};
|
||
if (options?.stressfreiEmailId) {
|
||
where.stressfreiEmailId = options.stressfreiEmailId;
|
||
}
|
||
if (options?.contractId) {
|
||
where.contractId = options.contractId;
|
||
}
|
||
return prisma.cachedEmail.findMany({
|
||
where,
|
||
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 (gleiche Filter wie getTrashEmails)
|
||
export async function getTrashCount(
|
||
customerId: number,
|
||
options?: { stressfreiEmailId?: number; contractId?: number },
|
||
): Promise<number> {
|
||
const where: Prisma.CachedEmailWhereInput = {
|
||
isDeleted: true,
|
||
stressfreiEmail: { customerId },
|
||
};
|
||
if (options?.stressfreiEmailId) {
|
||
where.stressfreiEmailId = options.stressfreiEmailId;
|
||
}
|
||
if (options?.contractId) {
|
||
where.contractId = options.contractId;
|
||
}
|
||
return prisma.cachedEmail.count({ where });
|
||
}
|
||
|
||
// 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,
|
||
},
|
||
});
|
||
}
|