From 993f2d10f0419bf6ffe49f3eaf0d49fe04c39d43 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 21 Jun 2026 14:06:24 +0200 Subject: [PATCH] E-Mail-Ansicht: Postfach-Filter in Trash/Sent durchreichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/controllers/cachedEmail.controller.ts | 33 +++++-- backend/src/services/cachedEmail.service.ts | 96 +++++++++++-------- docs/todo.md | 28 ++++++ .../email/ContractEmailsSection.tsx | 60 ++++++------ .../src/components/email/EmailClientTab.tsx | 14 ++- frontend/src/services/api.ts | 27 +++--- 6 files changed, 171 insertions(+), 87 deletions(-) diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 6d1b7fed..f1de989c 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -81,17 +81,22 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro } } -// E-Mails für einen Vertrag abrufen +// E-Mails für einen Vertrag abrufen. +// `accountId` (optional) schränkt zusätzlich auf ein bestimmtes Postfach +// ein – ohne, sieht man im Vertrags-Tab Mails aus ALLEN Postfächern des +// Kunden, die dem Vertrag zugeordnet sind (User-Bug 2026-06-21). export async function getEmailsForContract(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.contractId); if (!(await canAccessContract(req, res, contractId))) return; const folder = req.query.folder as string | undefined; // INBOX oder SENT + const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined; const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; const offset = req.query.offset ? parseInt(req.query.offset as string) : 0; const emails = await cachedEmailService.getCachedEmails({ contractId, + stressfreiEmailId, folder, limit, offset, @@ -238,13 +243,14 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise< } } -// E-Mail-Anzahl pro Ordner für einen Vertrag +// E-Mail-Anzahl pro Ordner für einen Vertrag (optional pro Postfach) export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.contractId); if (!(await canAccessContract(req, res, contractId))) return; + const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined; - const counts = await cachedEmailService.getFolderCountsForContract(contractId); + const counts = await cachedEmailService.getFolderCountsForContract(contractId, stressfreiEmailId); res.json({ success: true, data: counts } as ApiResponse); } catch (error) { @@ -882,13 +888,21 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; + const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined; + const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined; - const emails = await cachedEmailService.getTrashEmails(customerId); + const emails = await cachedEmailService.getTrashEmails(customerId, { + stressfreiEmailId, + contractId, + }); res.json({ success: true, data: emails } as ApiResponse); } catch (error) { @@ -900,13 +914,18 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; + const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined; + const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined; - const count = await cachedEmailService.getTrashCount(customerId); + const count = await cachedEmailService.getTrashCount(customerId, { + stressfreiEmailId, + contractId, + }); res.json({ success: true, data: { count } } as ApiResponse); } catch (error) { diff --git a/backend/src/services/cachedEmail.service.ts b/backend/src/services/cachedEmail.service.ts index 2caaa22e..fb95266a 100644 --- a/backend/src/services/cachedEmail.service.ts +++ b/backend/src/services/cachedEmail.service.ts @@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom 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<{ +// 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 [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 }, - }), + 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 }; + return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread }; } // Alle StressfreiEmails eines Kunden mit Mailbox @@ -904,14 +906,26 @@ export async function permanentDeleteEmail(id: number): Promise { +// 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 { + 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: { - isDeleted: true, - stressfreiEmail: { - customerId, - }, - }, + where, include: { stressfreiEmail: { select: { @@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise; } -// Papierkorb-E-Mails zählen -export async function getTrashCount(customerId: number): Promise { - return prisma.cachedEmail.count({ - where: { - isDeleted: true, - stressfreiEmail: { - customerId, - }, - }, - }); +// Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails) +export async function getTrashCount( + customerId: number, + options?: { stressfreiEmailId?: number; contractId?: number }, +): Promise { + 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) diff --git a/docs/todo.md b/docs/todo.md index f31b8be3..54d3412c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht** + - Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im + Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN Postfächern + des Kunden angezeigt, egal welches Postfach im Selector aktiv war. + Im Vertrag fehlte zusätzlich der Vertrags-Filter für den Papierkorb. + - Backend: + - `getEmailsForContract` controller akzeptiert jetzt `accountId`- + Query-Param und reicht ihn als `stressfreiEmailId` an + `getCachedEmails` weiter (der hat den Filter eh schon implementiert, + nur niemand hat ihn aufgerufen). + - `getTrashEmails` (controller + service) akzeptiert `accountId` und + `contractId` als optionale Filter. Default-Verhalten unverändert, + wenn keiner gesetzt ist. + - `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`, + bekommt zusätzlich `trash` + `trashUnread` ins Result – sonst läge + der Trash-Badge im Vertrag wieder account-global, während die Liste + contract-scoped ist. + - Frontend: + - `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts` + nehmen den Filter entgegen. + - `ContractEmailsSection` reicht `selectedAccountId` in alle drei + Queries durch und nimmt es in den queryKey mit auf – sonst greift + der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge + kommt jetzt aus den contract-scoped Counts, damit Badge und Liste + synchron laufen. + - `EmailClientTab` reicht `selectedAccountId` in die Trash-Query + durch (Inbox/Sent waren schon korrekt). + - [x] **🔒 Pentest R89 – Provider-Adressfelder härten** - R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)` statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit diff --git a/frontend/src/components/email/ContractEmailsSection.tsx b/frontend/src/components/email/ContractEmailsSection.tsx index fa9d6da6..796f7ba6 100644 --- a/frontend/src/components/email/ContractEmailsSection.tsx +++ b/frontend/src/components/email/ContractEmailsSection.tsx @@ -49,28 +49,44 @@ export default function ContractEmailsSection({ const selectedAccount = accounts.find((a) => a.id === selectedAccountId); - // E-Mails für den Vertrag laden (nach Ordner gefiltert, nicht für TRASH) + // E-Mails für den Vertrag laden (nach Ordner UND Postfach gefiltert). + // Bug 2026-06-21: vorher gingen Mails aus allen Postfächern in den + // gewählten Vertrags-Ordner – obwohl der User ein bestimmtes Postfach + // ausgewählt hatte. selectedAccountId muss in queryKey + queryFn. const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({ - queryKey: ['emails', 'contract', contractId, selectedFolder], - queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }), - enabled: selectedFolder !== 'TRASH', + queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder], + queryFn: () => cachedEmailApi.getForContract(contractId, { + folder: selectedFolder as 'INBOX' | 'SENT', + accountId: selectedAccountId ?? undefined, + }), + enabled: selectedFolder !== 'TRASH' && !!selectedAccountId, }); const emails = emailsData?.data || []; - // Papierkorb-E-Mails laden (für den ganzen Kunden, da Trash nicht vertragsgebunden) + // Papierkorb-E-Mails laden – jetzt strikt: nur das aktuell ausgewählte + // Postfach UND nur dem Vertrag zugeordnete Mails. Wenn man also den + // Vertrags-Papierkorb öffnet, sieht man nicht mehr alle gelöschten + // E-Mails des Kunden, sondern wirklich nur die, die diesem Vertrag + // aus diesem Postfach zugeordnet waren. const { data: trashData, isLoading: trashLoading } = useQuery({ - queryKey: ['emails', 'trash', customerId], - queryFn: () => cachedEmailApi.getTrash(customerId), - enabled: selectedFolder === 'TRASH' && canAccessTrash, + queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId], + queryFn: () => cachedEmailApi.getTrash(customerId, { + accountId: selectedAccountId ?? undefined, + contractId, + }), + enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId, }); const trashEmails = trashData?.data || []; - // Ordner-Anzahlen für Badges (Vertrag) + // Ordner-Anzahlen für Badges (Vertrag + Postfach). Badge und Liste + // müssen mit derselben Filter-Kombination laufen, sonst zeigt der + // Badge eine andere Zahl als die sichtbare Liste. const { data: folderCountsData } = useQuery({ - queryKey: ['contract-folder-counts', contractId], - queryFn: () => cachedEmailApi.getContractFolderCounts(contractId), + queryKey: ['contract-folder-counts', contractId, selectedAccountId], + queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined), + enabled: !!selectedAccountId, }); const folderCounts = folderCountsData?.data || { @@ -78,16 +94,6 @@ export default function ContractEmailsSection({ inboxUnread: 0, sent: 0, sentUnread: 0, - }; - - // Ordner-Anzahlen für das Konto (für Trash-Badge) - const { data: accountFolderCountsData } = useQuery({ - queryKey: ['folder-counts', selectedAccountId], - queryFn: () => stressfreiEmailApi.getFolderCounts(selectedAccountId!), - enabled: !!selectedAccountId && canAccessTrash, - }); - - const accountFolderCounts = accountFolderCountsData?.data || { trash: 0, trashUnread: 0, }; @@ -429,18 +435,18 @@ export default function ContractEmailsSection({ > Papierkorb - {accountFolderCounts.trash > 0 && ( + {folderCounts.trash > 0 && ( 0 + folderCounts.trashUnread > 0 ? 'bg-red-100 text-red-600 font-medium' : 'bg-gray-100 text-gray-500' }`} - title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`} + title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`} > - {accountFolderCounts.trashUnread > 0 - ? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}` - : accountFolderCounts.trash} + {folderCounts.trashUnread > 0 + ? `${folderCounts.trashUnread}/${folderCounts.trash}` + : folderCounts.trash} )} diff --git a/frontend/src/components/email/EmailClientTab.tsx b/frontend/src/components/email/EmailClientTab.tsx index 92f89d1b..3214e839 100644 --- a/frontend/src/components/email/EmailClientTab.tsx +++ b/frontend/src/components/email/EmailClientTab.tsx @@ -124,11 +124,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) { const emails = emailsData?.data || []; - // Papierkorb-E-Mails laden + // Papierkorb-E-Mails laden – jetzt strikt pro Postfach. + // Bug 2026-06-21: vorher kamen alle gelöschten E-Mails des Kunden + // raus, egal welches Postfach selektiert war. selectedAccountId muss + // in queryKey + queryFn, sonst greift React-Query-Cache bei Wechsel + // nicht und der Folder-Count aus folderCountsData liefe auseinander. const { data: trashData, isLoading: trashLoading } = useQuery({ - queryKey: ['emails', 'trash', customerId], - queryFn: () => cachedEmailApi.getTrash(customerId), - enabled: selectedFolder === 'TRASH' && canAccessTrash, + queryKey: ['emails', 'trash', customerId, selectedAccountId], + queryFn: () => cachedEmailApi.getTrash(customerId, { + accountId: selectedAccountId ?? undefined, + }), + enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId, }); const trashEmails = trashData?.data || []; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f545e341..379a825c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -597,19 +597,24 @@ export const cachedEmailApi = { const res = await api.get>(`/customers/${customerId}/emails`, { params: options }); return res.data; }, - // E-Mails für Vertrag abrufen - getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => { + // E-Mails für Vertrag abrufen (optional pro Postfach gefiltert) + getForContract: async ( + contractId: number, + options?: { folder?: 'INBOX' | 'SENT'; accountId?: number; limit?: number; offset?: number }, + ) => { const res = await api.get>(`/contracts/${contractId}/emails`, { params: options }); return res.data; }, - // Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails) - getContractFolderCounts: async (contractId: number) => { + // Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach) + getContractFolderCounts: async (contractId: number, accountId?: number) => { const res = await api.get>(`/contracts/${contractId}/emails/folder-counts`); + trash: number; + trashUnread: number; + }>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined }); return res.data; }, // Mailbox-Konten eines Kunden abrufen @@ -669,14 +674,14 @@ export const cachedEmailApi = { return res.data; }, // ==================== PAPIERKORB ==================== - // Papierkorb-E-Mails für Kunden abrufen - getTrash: async (customerId: number) => { - const res = await api.get>(`/customers/${customerId}/emails/trash`); + // Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert) + getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => { + const res = await api.get>(`/customers/${customerId}/emails/trash`, { params: options }); return res.data; }, - // Papierkorb-Anzahl für Kunden - getTrashCount: async (customerId: number) => { - const res = await api.get>(`/customers/${customerId}/emails/trash/count`); + // Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash) + getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => { + const res = await api.get>(`/customers/${customerId}/emails/trash/count`, { params: options }); return res.data; }, // E-Mail aus Papierkorb wiederherstellen