diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index e152bfa5..61ce7f34 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -25,6 +25,19 @@ import { // ==================== E-MAIL LIST ==================== +// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt). +function parseBoolParam(v: unknown): boolean | undefined { + if (v === 'true') return true; + if (v === 'false') return false; + return undefined; +} + +function parseDateParam(v: unknown): Date | undefined { + if (typeof v !== 'string' || !v.trim()) return undefined; + const d = new Date(v); + return isNaN(d.getTime()) ? undefined : d; +} + // E-Mails für einen Kunden abrufen export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise { try { @@ -42,6 +55,17 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro limit, offset, includeBody: false, + search: typeof req.query.search === 'string' ? req.query.search : undefined, + fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined, + toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined, + subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined, + bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined, + attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined, + hasAttachments: parseBoolParam(req.query.hasAttachments), + isRead: parseBoolParam(req.query.isRead), + isStarred: parseBoolParam(req.query.isStarred), + receivedFrom: parseDateParam(req.query.receivedFrom), + receivedTo: parseDateParam(req.query.receivedTo), }); res.json({ success: true, data: emails } as ApiResponse); diff --git a/backend/src/services/cachedEmail.service.ts b/backend/src/services/cachedEmail.service.ts index 2a16973e..2caaa22e 100644 --- a/backend/src/services/cachedEmail.service.ts +++ b/backend/src/services/cachedEmail.service.ts @@ -49,6 +49,18 @@ export interface EmailListOptions { 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 ==================== @@ -273,6 +285,59 @@ export async function getCachedEmails( 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, diff --git a/docs/todo.md b/docs/todo.md index 8d18fbc2..c01d79be 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-Postfach: Suche + erweiterte Filter (Variante B)** + - Suchleiste über der Email-Liste – durchsucht parallel Subject, + From-Address/Name und Body. + - Filter-Button mit Badge (Anzahl aktiver Filter) klappt eine Box mit + Detail-Filtern auf: Von, An, Betreff, Inhalt, Datum von/bis, + Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status. + Alle Filter werden im Backend mit UND verknüpft. + - „Alle zurücksetzen"-Button räumt komplett auf. + - Backend: `GET /api/customers/:id/emails` nimmt die Filter als + Query-Parameter entgegen, `getCachedEmails` übersetzt sie in eine + Prisma `where`-Klausel. + - **Bewusst nicht gebaut**: voller AND/OR-Builder mit Plus-Button und + Bool-Verschachtelung – Trade-off-Diskussion mit User: reale + Use-Cases sind quasi immer AND, UI-Komplexität verschachtelter + Bool-Builder bringt mehr Bedienprobleme als Mehrwert. + - [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren** - Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse (Tooltip erklärt: „ersetzt die Forwards am Provider durch diff --git a/frontend/src/components/email/EmailClientTab.tsx b/frontend/src/components/email/EmailClientTab.tsx index e71d95c1..51346c92 100644 --- a/frontend/src/components/email/EmailClientTab.tsx +++ b/frontend/src/components/email/EmailClientTab.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from 'react'; -import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react'; +import { useState, useEffect, useMemo } from 'react'; +import { RefreshCw, Plus, Mail, Inbox, Send, Trash2, Search, SlidersHorizontal, X } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; -import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api'; +import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } from '../../services/api'; import { useAuth } from '../../context/AuthContext'; import { useProviderSettings } from '../../hooks/useProviderSettings'; import Button from '../ui/Button'; @@ -26,6 +26,67 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) { const [showCompose, setShowCompose] = useState(false); const [showAssign, setShowAssign] = useState(false); const [replyToEmail, setReplyToEmail] = useState(null); + + // Such- und Filterzustand. Alle Filter sind AND-verknüpft im Backend. + const [searchQuery, setSearchQuery] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [filterFrom, setFilterFrom] = useState(''); + const [filterTo, setFilterTo] = useState(''); + const [filterSubject, setFilterSubject] = useState(''); + const [filterBody, setFilterBody] = useState(''); + const [filterAttachmentName, setFilterAttachmentName] = useState(''); + const [filterHasAttachments, setFilterHasAttachments] = useState<'any' | 'yes' | 'no'>('any'); + const [filterReadStatus, setFilterReadStatus] = useState<'any' | 'unread' | 'read'>('any'); + const [filterStarred, setFilterStarred] = useState<'any' | 'starred'>('any'); + const [filterDateFrom, setFilterDateFrom] = useState(''); + const [filterDateTo, setFilterDateTo] = useState(''); + + // Filter-Parameter (memoized) – fließen in queryKey + queryFn. + const filterParams: EmailFilterParams = useMemo(() => { + const p: EmailFilterParams = {}; + if (searchQuery.trim()) p.search = searchQuery.trim(); + if (filterFrom.trim()) p.fromFilter = filterFrom.trim(); + if (filterTo.trim()) p.toFilter = filterTo.trim(); + if (filterSubject.trim()) p.subjectFilter = filterSubject.trim(); + if (filterBody.trim()) p.bodyFilter = filterBody.trim(); + if (filterAttachmentName.trim()) p.attachmentNameFilter = filterAttachmentName.trim(); + if (filterHasAttachments === 'yes') p.hasAttachments = true; + if (filterHasAttachments === 'no') p.hasAttachments = false; + if (filterReadStatus === 'read') p.isRead = true; + if (filterReadStatus === 'unread') p.isRead = false; + if (filterStarred === 'starred') p.isStarred = true; + if (filterDateFrom) p.receivedFrom = new Date(filterDateFrom + 'T00:00:00').toISOString(); + if (filterDateTo) p.receivedTo = new Date(filterDateTo + 'T23:59:59').toISOString(); + return p; + }, [ + searchQuery, + filterFrom, + filterTo, + filterSubject, + filterBody, + filterAttachmentName, + filterHasAttachments, + filterReadStatus, + filterStarred, + filterDateFrom, + filterDateTo, + ]); + + const activeFilterCount = Object.keys(filterParams).length; + + const clearAllFilters = () => { + setSearchQuery(''); + setFilterFrom(''); + setFilterTo(''); + setFilterSubject(''); + setFilterBody(''); + setFilterAttachmentName(''); + setFilterHasAttachments('any'); + setFilterReadStatus('any'); + setFilterStarred('any'); + setFilterDateFrom(''); + setFilterDateTo(''); + }; const queryClient = useQueryClient(); const { hasPermission } = useAuth(); @@ -50,11 +111,12 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) { // E-Mails laden (nur für INBOX und SENT) const { data: emailsData, isLoading: emailsLoading, refetch: refetchEmails } = useQuery({ - queryKey: ['emails', 'customer', customerId, selectedAccountId, selectedFolder], + queryKey: ['emails', 'customer', customerId, selectedAccountId, selectedFolder, filterParams], queryFn: () => cachedEmailApi.getForCustomer(customerId, { accountId: selectedAccountId || undefined, folder: selectedFolder as 'INBOX' | 'SENT', + ...filterParams, }), enabled: !!selectedAccountId && selectedFolder !== 'TRASH', }); @@ -302,7 +364,144 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) { {/* Content */}
{/* Email List */} -
+
+ {selectedFolder !== 'TRASH' && selectedAccountId && ( +
+ {/* Suchleiste */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Suche in Betreff, Absender, Inhalt…" + className="w-full pl-8 pr-7 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> + {searchQuery && ( + + )} +
+ +
+ + {/* Ausklappbare erweiterte Filter (alle AND-verknüpft) */} + {showFilters && ( +
+
+ setFilterFrom(e.target.value)} + placeholder="Von (Adresse/Name)" + className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + setFilterTo(e.target.value)} + placeholder="An" + className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + setFilterSubject(e.target.value)} + placeholder="Betreff enthält" + className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + setFilterBody(e.target.value)} + placeholder="Inhalt enthält" + className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + setFilterDateFrom(e.target.value)} + title="Empfangen ab" + className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + setFilterDateTo(e.target.value)} + title="Empfangen bis" + className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + setFilterAttachmentName(e.target.value)} + placeholder="Anhang-Dateiname" + className="col-span-2 px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ + + +
+
+ Filter werden mit UND verknüpft. + {activeFilterCount > 0 && ( + + )} +
+
+ )} +
+ )} +
{selectedFolder === 'TRASH' ? ( )} +
{/* Email Detail */} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a87d9835..9adc33a7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -463,9 +463,28 @@ export const stressfreiEmailApi = { }; // Cached Email API (E-Mail-Client) +export interface EmailFilterParams { + accountId?: number; + folder?: 'INBOX' | 'SENT'; + limit?: number; + offset?: number; + // Suche / Filter (alle AND-verknüpft) + search?: string; + fromFilter?: string; + toFilter?: string; + subjectFilter?: string; + bodyFilter?: string; + attachmentNameFilter?: string; + hasAttachments?: boolean; + isRead?: boolean; + isStarred?: boolean; + receivedFrom?: string; // ISO date + receivedTo?: string; // ISO date +} + export const cachedEmailApi = { // E-Mails für Kunden abrufen - getForCustomer: async (customerId: number, options?: { accountId?: number; folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => { + getForCustomer: async (customerId: number, options?: EmailFilterParams) => { const res = await api.get>(`/customers/${customerId}/emails`, { params: options }); return res.data; },