diff --git a/backend/prisma/migrations/20260727110000_email_folder_spam/migration.sql b/backend/prisma/migrations/20260727110000_email_folder_spam/migration.sql new file mode 100644 index 00000000..7c8255f2 --- /dev/null +++ b/backend/prisma/migrations/20260727110000_email_folder_spam/migration.sql @@ -0,0 +1,6 @@ +-- EmailFolder-Enum um SPAM erweitern (Spam-/Junk-Ordner des Postfachs). +-- Neuer Wert wird ans Ende gehängt → bestehende INBOX/SENT-Zeilen bleiben +-- unverändert, kein Rewrite. + +ALTER TABLE `CachedEmail` + MODIFY COLUMN `folder` ENUM('INBOX', 'SENT', 'SPAM') NOT NULL DEFAULT 'INBOX'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4ff4bb23..778703fd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -448,6 +448,7 @@ model StressfreiEmail { enum EmailFolder { INBOX SENT + SPAM } model CachedEmail { diff --git a/backend/src/services/cachedEmail.service.ts b/backend/src/services/cachedEmail.service.ts index ccbcb207..295bd004 100644 --- a/backend/src/services/cachedEmail.service.ts +++ b/backend/src/services/cachedEmail.service.ts @@ -4,7 +4,7 @@ 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, humanizeImapError } from './imapService.js'; +import { fetchEmails, ImapCredentials, FetchedEmail, moveToTrash, restoreFromTrash, permanentDelete, humanizeImapError, findJunkFolderPath } from './imapService.js'; import { getImapSmtpSettings } from './emailProvider/emailProviderService.js'; // ==================== TYPES ==================== @@ -68,7 +68,7 @@ export interface EmailListOptions { // E-Mails für eine StressfreiEmail synchronisieren export async function syncEmailsForAccount( stressfreiEmailId: number, - options?: { folder?: string; fullSync?: boolean } + options?: { folder?: string; fullSync?: boolean; dbFolder?: EmailFolder } ): Promise { const folder = options?.folder || 'INBOX'; const fullSync = options?.fullSync || false; @@ -111,10 +111,12 @@ export async function syncEmailsForAccount( allowSelfSignedCerts: settings.allowSelfSignedCerts, }; - // Folder-Mapping: IMAP-Ordner zu DB-Ordner - const dbFolder = folder.toUpperCase() === 'SENT' || folder.toLowerCase() === 'sent' - ? EmailFolder.SENT - : EmailFolder.INBOX; + // Folder-Mapping: IMAP-Ordner zu DB-Ordner. Explizites dbFolder (z.B. + // SPAM für den Junk-Ordner, dessen IMAP-Name variiert) hat Vorrang. + const dbFolder = options?.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; @@ -168,7 +170,33 @@ export async function syncEmailsForAccount( } } -// Alle Ordner synchronisieren (INBOX + SENT) +/** + * Baut die IMAP-Credentials für ein Postfach (für Operationen, die eine + * eigene Verbindung brauchen – z.B. Junk-Ordner-Pfad ermitteln). Gibt null + * zurück, wenn kein Postfach/Passwort/Provider-Settings vorhanden sind. + */ +async function getImapCredentialsForAccount(stressfreiEmailId: number): Promise { + const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ where: { id: stressfreiEmailId } }); + if (!stressfreiEmail?.hasMailbox || !stressfreiEmail.emailPasswordEncrypted) return null; + const settings = await getImapSmtpSettings(); + if (!settings) return null; + let password: string; + try { + password = decrypt(stressfreiEmail.emailPasswordEncrypted); + } catch { + return null; + } + return { + host: settings.imapServer, + port: settings.imapPort, + user: stressfreiEmail.email, + password, + encryption: settings.imapEncryption, + allowSelfSignedCerts: settings.allowSelfSignedCerts, + }; +} + +// Alle Ordner synchronisieren (INBOX + SENT + SPAM/Junk) export async function syncAllFoldersForAccount( stressfreiEmailId: number, options?: { fullSync?: boolean } @@ -181,6 +209,23 @@ export async function syncAllFoldersForAccount( // SENT synchronisieren (Plesk verwendet "Sent" als Ordnername) const sentResult = await syncEmailsForAccount(stressfreiEmailId, { folder: 'Sent', fullSync }); + // SPAM/Junk synchronisieren – Ordnername variiert je Server, daher erst + // per IMAP ermitteln. Existiert kein Junk-Ordner, wird Spam übersprungen + // (kein Fehler). Wird explizit als dbFolder=SPAM abgelegt. + let spamNew = 0; + const credentials = await getImapCredentialsForAccount(stressfreiEmailId); + if (credentials) { + const junkPath = await findJunkFolderPath(credentials); + if (junkPath) { + const spamResult = await syncEmailsForAccount(stressfreiEmailId, { + folder: junkPath, + dbFolder: EmailFolder.SPAM, + fullSync, + }); + if (spamResult.success) spamNew = spamResult.newEmails; + } + } + // Ergebnisse kombinieren if (!inboxResult.success && !sentResult.success) { return { @@ -193,7 +238,7 @@ export async function syncAllFoldersForAccount( return { success: true, - newEmails: inboxResult.newEmails + sentResult.newEmails, + newEmails: inboxResult.newEmails + sentResult.newEmails + spamNew, totalEmails: inboxResult.totalEmails, }; } @@ -280,9 +325,11 @@ export async function getCachedEmails( where.contractId = options.contractId; } - // Folder-Filter (INBOX oder SENT) + // Folder-Filter (INBOX, SENT oder SPAM) if (options.folder === 'SENT') { where.folder = EmailFolder.SENT; + } else if (options.folder === 'SPAM') { + where.folder = EmailFolder.SPAM; } else if (options.folder === 'INBOX' || !options.folder) { // Standard: Nur Posteingang where.folder = EmailFolder.INBOX; @@ -573,10 +620,12 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom inboxUnread: number; sent: number; sentUnread: number; + spam: number; + spamUnread: number; trash: number; trashUnread: number; }> { - const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([ + const [inbox, inboxUnread, sent, sentUnread, spam, spamUnread, trash, trashUnread] = await Promise.all([ // INBOX total prisma.cachedEmail.count({ where: { stressfreiEmailId, folder: EmailFolder.INBOX, isDeleted: false }, @@ -593,6 +642,14 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom prisma.cachedEmail.count({ where: { stressfreiEmailId, folder: EmailFolder.SENT, isDeleted: false, isRead: false }, }), + // SPAM total + prisma.cachedEmail.count({ + where: { stressfreiEmailId, folder: EmailFolder.SPAM, isDeleted: false }, + }), + // SPAM unread + prisma.cachedEmail.count({ + where: { stressfreiEmailId, folder: EmailFolder.SPAM, isDeleted: false, isRead: false }, + }), // TRASH total (isDeleted = true) prisma.cachedEmail.count({ where: { stressfreiEmailId, isDeleted: true }, @@ -603,7 +660,7 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom }), ]); - return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread }; + return { inbox, inboxUnread, sent, sentUnread, spam, spamUnread, trash, trashUnread }; } // E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails). @@ -759,8 +816,17 @@ export async function moveEmailToTrash(id: number): Promise { const mailboxes = await client.list(); @@ -803,6 +806,48 @@ async function findTrashFolder(client: ImapFlow): Promise { return null; } +/** + * Ermittelt den tatsächlichen Pfad des Spam-/Junk-Ordners eines Postfachs. + * Bevorzugt das IMAP-Special-Use-Flag \Junk, fällt sonst auf übliche + * Ordnernamen zurück. Öffnet eine eigene Verbindung; gibt null zurück, + * wenn kein Junk-Ordner existiert (dann wird Spam einfach nicht synct). + */ +export async function findJunkFolderPath(credentials: ImapCredentials): Promise { + const encryption = credentials.encryption ?? 'SSL'; + const clientOptions: ConstructorParameters[0] = { + host: credentials.host, + port: credentials.port, + secure: encryption === 'SSL', + auth: { user: credentials.user, pass: credentials.password }, + logger: false, + }; + if (encryption !== 'NONE') { + const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any; + } + + const client = new ImapFlow(clientOptions); + try { + await client.connect(); + const mailboxes = await client.list(); + // Zuerst per Special-Use-Flag \Junk + const byFlag = mailboxes.find(m => m.specialUse === '\\Junk'); + if (byFlag) return byFlag.path; + // Sonst per Namensliste + for (const name of JUNK_FOLDER_NAMES) { + const found = mailboxes.find(m => + m.path.toLowerCase() === name.toLowerCase() || + m.name.toLowerCase() === name.toLowerCase() + ); + if (found) return found.path; + } + return null; + } catch { + return null; + } finally { + try { await client.logout(); } catch { /* ignore */ } + } +} + export interface MoveToTrashResult { success: boolean; newUid?: number; // UID im Trash-Ordner diff --git a/docs/todo.md b/docs/todo.md index daf83e59..14a6ebf7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **📧 E-Mail-Client: Spam-Ordner als eigener Tab** + - Neuer Tab „Spam" (zwischen Gesendet und Papierkorb) im E-Mail-Client, + zeigt den Junk-/Spam-Ordner des gewählten Postfachs – damit + fälschlich als Spam eingestufte Mails auffindbar sind. + - Backend: `EmailFolder`-Enum um `SPAM` erweitert (Migration + `20260727110000_email_folder_spam`, Enum-Wert angehängt). IMAP-Sync + ermittelt den Junk-Ordnerpfad per Special-Use-Flag `\Junk` + + Namensliste (`findJunkFolderPath`), `syncAllFoldersForAccount` synct + ihn zusätzlich als `dbFolder=SPAM`. `getCachedEmails` + + `getFolderCountsForAccount` um SPAM erweitert. + - Papierkorb-Verschieben/Wiederherstellen für Spam-Mails nutzt den + tatsächlichen Junk-Pfad als Quell-/Zielordner. + - Frontend: Tab + Badge (ungelesen/gesamt), nutzt denselben + List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam + möglich (Rettung legitimer Mails). + - [x] **📱 Mobilfunk: Feld „Mobilfunknetz" unter Anbieter & Tarif** - Neues Dropdown „Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur sichtbar wenn Vertragstyp = Mobilfunk. Optionen: Bitte auswählen diff --git a/frontend/src/components/email/EmailClientTab.tsx b/frontend/src/components/email/EmailClientTab.tsx index 16257596..c84b3911 100644 --- a/frontend/src/components/email/EmailClientTab.tsx +++ b/frontend/src/components/email/EmailClientTab.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { RefreshCw, Plus, Mail, Inbox, Send, Trash2, Search, SlidersHorizontal, X } from 'lucide-react'; +import { RefreshCw, Plus, Mail, Inbox, Send, Trash2, Search, SlidersHorizontal, X, ShieldAlert } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } from '../../services/api'; @@ -13,7 +13,7 @@ import ComposeEmailModal from './ComposeEmailModal'; import AssignToContractModal from './AssignToContractModal'; import TrashEmailList from './TrashEmailList'; -type EmailFolder = 'INBOX' | 'SENT' | 'TRASH'; +type EmailFolder = 'INBOX' | 'SENT' | 'SPAM' | 'TRASH'; interface EmailClientTabProps { customerId: number; @@ -117,7 +117,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) { queryFn: () => cachedEmailApi.getForCustomer(customerId, { accountId: selectedAccountId || undefined, - folder: selectedFolder as 'INBOX' | 'SENT', + folder: selectedFolder as 'INBOX' | 'SENT' | 'SPAM', ...filterParams, }), enabled: !!selectedAccountId && selectedFolder !== 'TRASH', @@ -152,6 +152,8 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) { inboxUnread: 0, sent: 0, sentUnread: 0, + spam: 0, + spamUnread: 0, trash: 0, trashUnread: 0, }; @@ -396,6 +398,31 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) { )} + {canAccessTrash && (