E-Mail-Ansicht: Postfach-Filter in Trash/Sent durchreichen

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>
This commit is contained in:
2026-06-21 14:06:24 +02:00
parent f02824fe7d
commit 993f2d10f0
6 changed files with 171 additions and 87 deletions
@@ -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<void> { export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return; if (!(await canAccessContract(req, res, contractId))) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT 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 limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0; const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
const emails = await cachedEmailService.getCachedEmails({ const emails = await cachedEmailService.getCachedEmails({
contractId, contractId,
stressfreiEmailId,
folder, folder,
limit, limit,
offset, 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<void> { export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try { try {
const contractId = parseInt(req.params.contractId); const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return; 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); res.json({ success: true, data: counts } as ApiResponse);
} catch (error) { } catch (error) {
@@ -882,13 +888,21 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
// ==================== TRASH OPERATIONS ==================== // ==================== TRASH OPERATIONS ====================
// Papierkorb-E-Mails für einen Kunden abrufen // Papierkorb-E-Mails für einen Kunden abrufen.
// Optional `accountId` (Postfach-Filter) und `contractId` (Vertrags-Filter)
// beide aus User-Bug 2026-06-21. Wenn beide leer sind, Verhalten wie
// vorher: alle gelöschten E-Mails des Kunden.
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> { export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return; 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); res.json({ success: true, data: emails } as ApiResponse);
} catch (error) { } catch (error) {
@@ -900,13 +914,18 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise<v
} }
} }
// Papierkorb-Anzahl für einen Kunden // Papierkorb-Anzahl für einen Kunden (gleiche Filter wie getTrashEmails)
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> { export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
try { try {
const customerId = parseInt(req.params.customerId); const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return; 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); res.json({ success: true, data: { count } } as ApiResponse);
} catch (error) { } catch (error) {
+57 -37
View File
@@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread }; return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
} }
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails) // E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
export async function getFolderCountsForContract(contractId: number): Promise<{ // 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; inbox: number;
inboxUnread: number; inboxUnread: number;
sent: number; sent: number;
sentUnread: number; sentUnread: number;
trash: number;
trashUnread: number;
}> { }> {
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([ const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
// INBOX total if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
prisma.cachedEmail.count({ const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false }, prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
}), prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
// INBOX unread prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
prisma.cachedEmail.count({ prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false }, prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
}), prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, 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, trash, trashUnread };
return { inbox, inboxUnread, sent, sentUnread };
} }
// Alle StressfreiEmails eines Kunden mit Mailbox // Alle StressfreiEmails eines Kunden mit Mailbox
@@ -904,14 +906,26 @@ export async function permanentDeleteEmail(id: number): Promise<TrashOperationRe
} }
// Papierkorb-E-Mails für einen Kunden abrufen // Papierkorb-E-Mails für einen Kunden abrufen
export async function getTrashEmails(customerId: number): Promise<CachedEmailWithRelations[]> { // Optional: nach Postfach (stressfreiEmailId) und/oder Vertrag (contractId)
return prisma.cachedEmail.findMany({ // einschränken. Vorher zeigte der Papierkorb immer ALLE gelöschten E-Mails
where: { // 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, isDeleted: true,
stressfreiEmail: { stressfreiEmail: { customerId },
customerId, };
}, if (options?.stressfreiEmailId) {
}, where.stressfreiEmailId = options.stressfreiEmailId;
}
if (options?.contractId) {
where.contractId = options.contractId;
}
return prisma.cachedEmail.findMany({
where,
include: { include: {
stressfreiEmail: { stressfreiEmail: {
select: { select: {
@@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise<CachedEmailWit
}) as Promise<CachedEmailWithRelations[]>; }) as Promise<CachedEmailWithRelations[]>;
} }
// Papierkorb-E-Mails zählen // Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails)
export async function getTrashCount(customerId: number): Promise<number> { export async function getTrashCount(
return prisma.cachedEmail.count({ customerId: number,
where: { options?: { stressfreiEmailId?: number; contractId?: number },
): Promise<number> {
const where: Prisma.CachedEmailWhereInput = {
isDeleted: true, isDeleted: true,
stressfreiEmail: { stressfreiEmail: { customerId },
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) // Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
+28
View File
@@ -97,6 +97,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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** - [x] **🔒 Pentest R89 Provider-Adressfelder härten**
- R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)` - R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)`
statt 400 501+ Zeichen wurden auf 500 abgeschnitten und mit statt 400 501+ Zeichen wurden auf 500 abgeschnitten und mit
@@ -49,28 +49,44 @@ export default function ContractEmailsSection({
const selectedAccount = accounts.find((a) => a.id === selectedAccountId); 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({ const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
queryKey: ['emails', 'contract', contractId, selectedFolder], queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder],
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }), queryFn: () => cachedEmailApi.getForContract(contractId, {
enabled: selectedFolder !== 'TRASH', folder: selectedFolder as 'INBOX' | 'SENT',
accountId: selectedAccountId ?? undefined,
}),
enabled: selectedFolder !== 'TRASH' && !!selectedAccountId,
}); });
const emails = emailsData?.data || []; 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({ const { data: trashData, isLoading: trashLoading } = useQuery({
queryKey: ['emails', 'trash', customerId], queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId],
queryFn: () => cachedEmailApi.getTrash(customerId), queryFn: () => cachedEmailApi.getTrash(customerId, {
enabled: selectedFolder === 'TRASH' && canAccessTrash, accountId: selectedAccountId ?? undefined,
contractId,
}),
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
}); });
const trashEmails = trashData?.data || []; 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({ const { data: folderCountsData } = useQuery({
queryKey: ['contract-folder-counts', contractId], queryKey: ['contract-folder-counts', contractId, selectedAccountId],
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId), queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined),
enabled: !!selectedAccountId,
}); });
const folderCounts = folderCountsData?.data || { const folderCounts = folderCountsData?.data || {
@@ -78,16 +94,6 @@ export default function ContractEmailsSection({
inboxUnread: 0, inboxUnread: 0,
sent: 0, sent: 0,
sentUnread: 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, trash: 0,
trashUnread: 0, trashUnread: 0,
}; };
@@ -429,18 +435,18 @@ export default function ContractEmailsSection({
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
Papierkorb Papierkorb
{accountFolderCounts.trash > 0 && ( {folderCounts.trash > 0 && (
<span <span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${ className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
accountFolderCounts.trashUnread > 0 folderCounts.trashUnread > 0
? 'bg-red-100 text-red-600 font-medium' ? 'bg-red-100 text-red-600 font-medium'
: 'bg-gray-100 text-gray-500' : 'bg-gray-100 text-gray-500'
}`} }`}
title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`} title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
> >
{accountFolderCounts.trashUnread > 0 {folderCounts.trashUnread > 0
? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}` ? `${folderCounts.trashUnread}/${folderCounts.trash}`
: accountFolderCounts.trash} : folderCounts.trash}
</span> </span>
)} )}
</button> </button>
@@ -124,11 +124,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const emails = emailsData?.data || []; 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({ const { data: trashData, isLoading: trashLoading } = useQuery({
queryKey: ['emails', 'trash', customerId], queryKey: ['emails', 'trash', customerId, selectedAccountId],
queryFn: () => cachedEmailApi.getTrash(customerId), queryFn: () => cachedEmailApi.getTrash(customerId, {
enabled: selectedFolder === 'TRASH' && canAccessTrash, accountId: selectedAccountId ?? undefined,
}),
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
}); });
const trashEmails = trashData?.data || []; const trashEmails = trashData?.data || [];
+16 -11
View File
@@ -597,19 +597,24 @@ export const cachedEmailApi = {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options }); const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
return res.data; return res.data;
}, },
// E-Mails für Vertrag abrufen // E-Mails für Vertrag abrufen (optional pro Postfach gefiltert)
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => { getForContract: async (
contractId: number,
options?: { folder?: 'INBOX' | 'SENT'; accountId?: number; limit?: number; offset?: number },
) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options }); const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
return res.data; return res.data;
}, },
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails) // Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach)
getContractFolderCounts: async (contractId: number) => { getContractFolderCounts: async (contractId: number, accountId?: number) => {
const res = await api.get<ApiResponse<{ const res = await api.get<ApiResponse<{
inbox: number; inbox: number;
inboxUnread: number; inboxUnread: number;
sent: number; sent: number;
sentUnread: number; sentUnread: number;
}>>(`/contracts/${contractId}/emails/folder-counts`); trash: number;
trashUnread: number;
}>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined });
return res.data; return res.data;
}, },
// Mailbox-Konten eines Kunden abrufen // Mailbox-Konten eines Kunden abrufen
@@ -669,14 +674,14 @@ export const cachedEmailApi = {
return res.data; return res.data;
}, },
// ==================== PAPIERKORB ==================== // ==================== PAPIERKORB ====================
// Papierkorb-E-Mails für Kunden abrufen // Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert)
getTrash: async (customerId: number) => { getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`); const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`, { params: options });
return res.data; return res.data;
}, },
// Papierkorb-Anzahl für Kunden // Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash)
getTrashCount: async (customerId: number) => { getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`); const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`, { params: options });
return res.data; return res.data;
}, },
// E-Mail aus Papierkorb wiederherstellen // E-Mail aus Papierkorb wiederherstellen