E-Mail-Client: Spam-Ordner als eigener Tab

Neuer Tab "Spam" (zwischen Gesendet und Papierkorb), zeigt den Junk-/
Spam-Ordner des gewählten Postfachs – damit fälschlich als Spam
einsortierte Mails auffindbar sind.

Backend:
- EmailFolder-Enum um SPAM erweitert (Migration, Wert angehängt →
  kein Rewrite bestehender Zeilen).
- imapService.findJunkFolderPath: ermittelt den Junk-Ordner per
  Special-Use-Flag \Junk + üblicher Namensliste (Junk/Spam/…).
- syncAllFoldersForAccount synct den Junk-Ordner zusätzlich als
  dbFolder=SPAM (syncEmailsForAccount bekommt dbFolder-Option).
- getCachedEmails + getFolderCountsForAccount um SPAM erweitert.
- Papierkorb-Move/Restore für Spam-Mails nutzt den echten Junk-Pfad
  als Quell-/Zielordner.

Frontend: Tab + Badge (ungelesen/gesamt), gleicher List-/Detail-Pfad
wie INBOX; Zuordnen-zu-Vertrag auch aus Spam möglich.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:52:24 +02:00
co-authored by Claude Opus 4.7
parent 8eb3790e7b
commit dfb4dadae1
8 changed files with 194 additions and 23 deletions
@@ -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';
+1
View File
@@ -448,6 +448,7 @@ model StressfreiEmail {
enum EmailFolder {
INBOX
SENT
SPAM
}
model CachedEmail {
+89 -15
View File
@@ -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<SyncResult> {
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<ImapCredentials | null> {
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<TrashOperationResult
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
// Ordner bestimmen (INBOX oder Sent)
const sourceFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
// IMAP-Quellordner bestimmen. Bei SPAM den tatsächlichen Junk-Pfad
// ermitteln (Name variiert je Server) sonst würde moveToTrash im
// falschen Ordner nach der UID suchen.
let sourceFolder: string;
if (email.folder === 'SENT') {
sourceFolder = 'Sent';
} else if (email.folder === 'SPAM') {
sourceFolder = (await findJunkFolderPath(credentials)) || 'Junk';
} else {
sourceFolder = 'INBOX';
}
// Auf IMAP-Server in Trash verschieben
const imapResult = await moveToTrash(credentials, email.uid, sourceFolder);
@@ -826,8 +892,16 @@ export async function restoreEmailFromTrash(id: number): Promise<TrashOperationR
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
// Ziel-Ordner bestimmen (basierend auf originalem Ordner)
const targetFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
// Ziel-Ordner bestimmen (basierend auf originalem Ordner). SPAM zurück
// in den tatsächlichen Junk-Ordner (Name variiert je Server).
let targetFolder: string;
if (email.folder === 'SENT') {
targetFolder = 'Sent';
} else if (email.folder === 'SPAM') {
targetFolder = (await findJunkFolderPath(credentials)) || 'Junk';
} else {
targetFolder = 'INBOX';
}
// Auf IMAP-Server aus Trash wiederherstellen
const imapResult = await restoreFromTrash(credentials, email.uid, targetFolder);
+45
View File
@@ -784,6 +784,9 @@ export async function fetchAttachmentList(
// Typische Namen für Trash-Ordner (verschiedene Server/Sprachen)
const TRASH_FOLDER_NAMES = ['Trash', 'INBOX.Trash', 'Deleted', 'Deleted Items', 'Deleted Messages', 'Papierkorb'];
// Übliche Namen für den Spam-/Junk-Ordner (Plesk/Dovecot: "Junk").
const JUNK_FOLDER_NAMES = ['Junk', 'INBOX.Junk', 'Spam', 'INBOX.Spam', 'Junk E-mail', 'Junk Email', 'Bulk Mail'];
// Helper: Trash-Ordner finden
async function findTrashFolder(client: ImapFlow): Promise<string | null> {
const mailboxes = await client.list();
@@ -803,6 +806,48 @@ async function findTrashFolder(client: ImapFlow): Promise<string | null> {
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<string | null> {
const encryption = credentials.encryption ?? 'SSL';
const clientOptions: ConstructorParameters<typeof ImapFlow>[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
+16
View File
@@ -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
@@ -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) {
</span>
)}
</button>
<button
onClick={() => handleFolderChange('SPAM')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
selectedFolder === 'SPAM'
? 'bg-white text-amber-600 shadow-sm font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<ShieldAlert className="w-4 h-4" />
Spam
{folderCounts.spam > 0 && (
<span
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
folderCounts.spamUnread > 0
? 'bg-amber-100 text-amber-600 font-medium'
: 'bg-gray-100 text-gray-500'
}`}
title={`${folderCounts.spamUnread} ungelesen / ${folderCounts.spam} gesamt`}
>
{folderCounts.spamUnread > 0
? `${folderCounts.spamUnread}/${folderCounts.spam}`
: folderCounts.spam}
</span>
)}
</button>
{canAccessTrash && (
<button
onClick={() => handleFolderChange('TRASH')}
@@ -626,7 +653,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
queryClient.invalidateQueries({ queryKey: ['folder-counts', selectedAccountId] });
}}
isLoading={emailsLoading}
folder={selectedFolder as 'INBOX' | 'SENT'}
folder={selectedFolder as 'INBOX' | 'SENT' | 'SPAM'}
accountId={selectedAccountId}
/>
)}
+3 -3
View File
@@ -12,7 +12,7 @@ interface EmailListProps {
onSelectEmail: (email: CachedEmail) => void;
onEmailDeleted?: (emailId: number) => void;
isLoading?: boolean;
folder?: 'INBOX' | 'SENT';
folder?: 'INBOX' | 'SENT' | 'SPAM';
accountId?: number | null; // Für Folder-Count-Aktualisierung bei Lesen/Ungelesen
}
@@ -250,8 +250,8 @@ export default function EmailList({
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
{email.contract.contractNumber}
</span>
{/* X-Button nur für INBOX oder manuell zugeordnete gesendete E-Mails */}
{(folder === 'INBOX' || (folder === 'SENT' && !email.isAutoAssigned)) && (
{/* X-Button nur für empfangene (INBOX/SPAM) oder manuell zugeordnete gesendete E-Mails */}
{(folder === 'INBOX' || folder === 'SPAM' || (folder === 'SENT' && !email.isAutoAssigned)) && (
<button
onClick={(e) => handleUnassign(e, email.id)}
className="p-0.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
+3 -1
View File
@@ -585,6 +585,8 @@ export const stressfreiEmailApi = {
inboxUnread: number;
sent: number;
sentUnread: number;
spam: number;
spamUnread: number;
trash: number;
trashUnread: number;
}>>(`/stressfrei-emails/${id}/folder-counts`);
@@ -595,7 +597,7 @@ export const stressfreiEmailApi = {
// Cached Email API (E-Mail-Client)
export interface EmailFilterParams {
accountId?: number;
folder?: 'INBOX' | 'SENT';
folder?: 'INBOX' | 'SENT' | 'SPAM';
limit?: number;
offset?: number;
// Suche / Filter (alle AND-verknüpft)