diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index f69702b3..d5fe0e20 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -267,12 +267,16 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req, const account = req.query.account ? String(req.query.account) : null; // mailbox_state filter: no param → active (+ legacy NULL); else exact. const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active'; + // Optional full-table search (sender / subject) so results aren't truncated + // to the first page before matching. + const q = req.query.q ? String(req.query.q).trim().slice(0, 255) : ''; // 'accounting' matches legacy rows too (account_key was NULL before mig 154). const applyAccount = (qb) => { if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key')); else if (account) qb.where('account_key', account); if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state')); else qb.where('mailbox_state', state); + if (q) qb.where((b) => b.where('from_address', 'like', `%${q}%`).orWhere('subject', 'like', `%${q}%`)); return qb; }; const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first(); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 81de4a04..bb07dd52 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3136,6 +3136,24 @@ "backup": "Backup & Wiederherstellung", "system": "System-Updates", "other": "Sonstige" + }, + "customerMailbox": { + "title": "Kunden-Postfach (hello@)", + "subtitle": "Ein zweites Eingangspostfach für Kundenkommunikation. Die E-Mails erscheinen unter Nachrichten → Kunden; Anhänge werden nicht an die Buchhaltung weitergeleitet.", + "enabled": "Dieses Postfach jede Minute abrufen", + "outgoing": "Ausgang (SMTP)", + "outgoingHint": "Antworten aus diesem Postfach werden von hier gesendet. Leer lassen, um die globale Absenderadresse zu verwenden.", + "fromEmail": "Absenderadresse", + "smtpHost": "SMTP-Host", + "smtpUser": "SMTP-Benutzername", + "smtpPass": "SMTP-Passwort", + "smtpSsl": "SSL (465)", + "smtpStarttls": "STARTTLS (587)", + "save": "Kunden-Postfach speichern", + "savedToast": "Kunden-Postfach gespeichert.", + "requiredFields": "Host, Port und Benutzername sind erforderlich.", + "testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.", + "testFailed": "Verbindung fehlgeschlagen." } }, "cms": { @@ -5534,5 +5552,88 @@ "titlePlaceholder": "z. B. Hochzeitsvertrag Doe / Müller", "validUntil": "Unterzeichnen bis (optional)" } + }, + "messages": { + "title": "Nachrichten", + "subtitle": "Gesendete, automatische und eingehende E-Mails — an einem Ort.", + "sync": "Abrufen", + "newMessage": "Neue Nachricht", + "searchPlaceholder": "In diesem Ordner suchen…", + "account": { + "all": "Alle E-Mails", + "customers": "Kunden", + "accounting": "Buchhaltung", + "automated": "Automatisch" + }, + "folder": { + "inbox": "Posteingang", + "sent": "Gesendet", + "archived": "Archiviert", + "deleted": "Gelöscht" + }, + "unified": "Konten übergreifend", + "systemGenerated": "Systemgeneriert", + "acrossAccounts": "Über alle Konten", + "selectPrompt": "Nachricht zum Lesen auswählen", + "noMessages": "Keine Nachrichten", + "noSearchResults": "Keine Treffer", + "noSubject": "(kein Betreff)", + "from": "von", + "to": "An", + "reply": "Antworten", + "replyAll": "Allen antworten", + "forward": "Weiterleiten", + "archive": "Archivieren", + "delete": "Löschen", + "deleteForever": "Endgültig löschen", + "restore": "Wiederherstellen", + "bookExpense": "Als Ausgabe buchen", + "rebill": "An Kunden weiterverrechnen", + "createQuote": "Angebot", + "createContract": "Vertrag", + "createGallery": "Galerie", + "createInvoice": "Rechnung", + "doc": { + "quote": "Angebot", + "contract": "Vertrag", + "invoice": "Rechnung", + "gallery": "Galerie" + }, + "soon": "In einer späteren Phase verfügbar", + "viewDocument": "Dokument ansehen", + "openInAccounting": "Im Buchhaltungs-Posteingang öffnen", + "noInboundBody": "Für diese E-Mail wurde kein Nachrichtentext erfasst.", + "noBody": "Diese Nachricht wurde gesendet, bevor die Textspeicherung eingeführt wurde — keine Vorschau verfügbar.", + "loadError": "Diese Nachricht konnte nicht geladen werden.", + "attachments": "Anhang/Anhänge", + "notArchived": "noch nicht archiviert", + "sentAttachHint": "Gesendete Anhänge werden noch nicht archiviert — Phase 2.", + "document": "Dokument", + "previewUnavailable": "Vorschau nicht verfügbar", + "rasterNote": "Serverseitig gerenderte Vorschau — die Originaldatei erreicht den Browser nie.", + "close": "Schliessen", + "compose": "Nachricht verfassen", + "cancel": "Abbrechen", + "send": "Senden", + "subject": "Betreff", + "optional": "optional", + "bodyHint": "Bearbeite die Nachricht frei — füge vor dem Senden an beliebiger Stelle eine Notiz ein.", + "sendsFromHint": "Wird von deiner konfigurierten Absenderadresse gesendet.", + "sentToast": "Nachricht gesendet.", + "sendFailed": "Nachricht konnte nicht gesendet werden.", + "onWrote": "Am", + "customer": "Kunde", + "resolvingCustomer": "Absender wird einem Kunden zugeordnet…", + "noCustomerMatch": "Kein Kunde zu diesem Absender gefunden — oben suchen oder neuen Kunden anlegen.", + "createNewDoc": "Neues {{label}} erstellen", + "existingDocs": "Oder ein bestehendes referenzieren", + "noExistingDocs": "Für diesen Kunden gibt es noch keine Dokumente.", + "galleryCreateOnly": "Galerien sind event-basiert — dies öffnet den Event-Editor, wo du den Kunden zuweisen kannst.", + "syncOk": "Postfächer geprüft — {{count}} neu.", + "syncDisabled": "Eingehende E-Mails sind deaktiviert — unter Einstellungen → Funktionen aktivieren.", + "syncUnconfigured": "Zuerst ein Postfach unter Einstellungen → E-Mail konfigurieren.", + "syncBusy": "Es läuft bereits eine Synchronisierung.", + "syncFailed": "Synchronisierung fehlgeschlagen.", + "actionFailed": "Aktion fehlgeschlagen." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 692cbb12..03a5479b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2698,6 +2698,24 @@ "backup": "Backup & restore", "system": "System updates", "other": "Other" + }, + "customerMailbox": { + "title": "Customer mailbox (hello@)", + "subtitle": "A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.", + "enabled": "Poll this mailbox every minute", + "outgoing": "Outgoing (SMTP)", + "outgoingHint": "Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.", + "fromEmail": "From address", + "smtpHost": "SMTP Host", + "smtpUser": "SMTP Username", + "smtpPass": "SMTP Password", + "smtpSsl": "SSL (465)", + "smtpStarttls": "STARTTLS (587)", + "save": "Save Customer Mailbox", + "savedToast": "Customer mailbox saved.", + "requiredFields": "Host, port and username are required.", + "testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.", + "testFailed": "Connection failed." } }, "cms": { @@ -5532,5 +5550,88 @@ "titlePlaceholder": "e.g. Wedding contract Doe / Müller", "validUntil": "Sign by (optional)" } + }, + "messages": { + "title": "Messages", + "subtitle": "Sent, automated and incoming mail — one place.", + "sync": "Sync", + "newMessage": "New message", + "searchPlaceholder": "Search this folder…", + "account": { + "all": "All mail", + "customers": "Customers", + "accounting": "Accounting", + "automated": "Automated" + }, + "folder": { + "inbox": "Inbox", + "sent": "Sent", + "archived": "Archived", + "deleted": "Deleted" + }, + "unified": "Unified across accounts", + "systemGenerated": "System-generated", + "acrossAccounts": "Across all accounts", + "selectPrompt": "Select a message to read", + "noMessages": "No messages", + "noSearchResults": "No matches", + "noSubject": "(no subject)", + "from": "from", + "to": "To", + "reply": "Reply", + "replyAll": "Reply all", + "forward": "Forward", + "archive": "Archive", + "delete": "Delete", + "deleteForever": "Delete permanently", + "restore": "Restore", + "bookExpense": "Book as expense", + "rebill": "Re-bill to client", + "createQuote": "Quote", + "createContract": "Contract", + "createGallery": "Gallery", + "createInvoice": "Invoice", + "doc": { + "quote": "Quote", + "contract": "Contract", + "invoice": "Invoice", + "gallery": "Gallery" + }, + "soon": "Available in a later phase", + "viewDocument": "View document", + "openInAccounting": "Open in Accounting inbox", + "noInboundBody": "No message body was captured for this email.", + "noBody": "This message was sent before body capture was added, so no preview is available.", + "loadError": "Could not load this message.", + "attachments": "attachment(s)", + "notArchived": "not archived yet", + "sentAttachHint": "Sent attachments are not archived yet — Phase 2.", + "document": "Document", + "previewUnavailable": "Preview unavailable", + "rasterNote": "Server-rendered preview — the raw file never reaches the browser.", + "close": "Close", + "compose": "Compose message", + "cancel": "Cancel", + "send": "Send", + "subject": "Subject", + "optional": "optional", + "bodyHint": "Edit the message freely — add a note anywhere before sending.", + "sendsFromHint": "Sends from your configured outgoing address.", + "sentToast": "Message sent.", + "sendFailed": "Failed to send message.", + "onWrote": "On", + "customer": "Customer", + "resolvingCustomer": "Matching the sender to a customer…", + "noCustomerMatch": "No customer matched this sender — search for one or create a new customer above.", + "createNewDoc": "Create new {{label}}", + "existingDocs": "Or reference an existing one", + "noExistingDocs": "No existing documents for this customer yet.", + "galleryCreateOnly": "Galleries are event-based — this opens the event editor, where you can assign the customer.", + "syncOk": "Checked mailboxes — {{count}} new.", + "syncDisabled": "Incoming mail is off — enable it under Settings → Features.", + "syncUnconfigured": "Configure a mailbox under Settings → Email first.", + "syncBusy": "A sync is already running.", + "syncFailed": "Sync failed.", + "actionFailed": "Action failed." } } diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx index ea00cf6a..62bc5f25 100644 --- a/frontend/src/pages/admin/messages/MessagesPage.tsx +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -61,6 +61,14 @@ const localPart = (addr?: string | null) => (addr ? `${addr.split('@')[0]}@` : ' const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => (({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record)[c])); +// A From/To header can be "Display Name " — pull the bare address for +// use as a recipient / customer-lookup key. +const extractEmail = (addr?: string | null) => { + if (!addr) return ''; + const m = addr.match(/<([^>]+)>/); + return (m ? m[1] : addr).trim(); +}; + const STATUS_STYLES: Record = { sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', ingested: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', @@ -80,6 +88,14 @@ export const MessagesPage: React.FC = () => { const [docAction, setDocAction] = useState<{ docType: DocType; senderEmail: string } | null>(null); const { flags } = useFeatureFlags(); const [search, setSearch] = useState(''); + // Debounced copy drives the server-side search (so results aren't truncated to + // the first page); the raw `search` still filters the loaded rows instantly. + const [debouncedSearch, setDebouncedSearch] = useState(''); + useEffect(() => { + const id = setTimeout(() => setDebouncedSearch(search.trim()), 250); + return () => clearTimeout(id); + }, [search]); + const sq = debouncedSearch || undefined; // "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop. const sync = useMutation({ @@ -101,18 +117,18 @@ export const MessagesPage: React.FC = () => { }); const queueQuery = useQuery({ - queryKey: ['messages', 'queue'], - queryFn: () => emailService.listQueue({ pageSize: 100 }), + queryKey: ['messages', 'queue', sq], + queryFn: () => emailService.listQueue({ pageSize: 100, q: sq }), refetchInterval: 60000, }); const acctQuery = useQuery({ - queryKey: ['messages', 'received', 'accounting'], - queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100 }), + queryKey: ['messages', 'received', 'accounting', sq], + queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100, q: sq }), refetchInterval: 60000, }); const custQuery = useQuery({ - queryKey: ['messages', 'received', 'customers'], - queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100 }), + queryKey: ['messages', 'received', 'customers', sq], + queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100, q: sq }), refetchInterval: 60000, }); @@ -127,14 +143,14 @@ export const MessagesPage: React.FC = () => { const folderState: 'archived' | 'deleted' | undefined = activeFolder === 'archived' ? 'archived' : activeFolder === 'deleted' ? 'deleted' : undefined; const stateQueueQuery = useQuery({ - queryKey: ['messages', 'state-queue', folderState], + queryKey: ['messages', 'state-queue', folderState, sq], enabled: !!folderState, - queryFn: () => emailService.listQueue({ state: folderState as 'archived' | 'deleted', pageSize: 100 }), + queryFn: () => emailService.listQueue({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }), }); const stateRecvQuery = useQuery({ - queryKey: ['messages', 'state-received', folderState], + queryKey: ['messages', 'state-received', folderState, sq], enabled: !!folderState, - queryFn: () => emailService.listReceived({ state: folderState as 'archived' | 'deleted', pageSize: 100 }), + queryFn: () => emailService.listReceived({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }), }); const refetchAll = () => { @@ -524,9 +540,9 @@ const ReadingPane: React.FC<{ ? selection.item.account_key !== 'customers' : account.id === 'acct'; - const recipient = selection.kind === 'received' - ? (selection.item.from_address || '') - : (detailQuery.data?.recipientEmail || ''); + const recipient = extractEmail(selection.kind === 'received' + ? selection.item.from_address + : detailQuery.data?.recipientEmail); // Reply only makes sense for an inbound message with a sender. const onReply = selection.kind === 'received' && selection.item.from_address @@ -534,7 +550,7 @@ const ReadingPane: React.FC<{ const it = selection.item; const subj = /^re:/i.test(it.subject || '') ? (it.subject || '') : `Re: ${it.subject || ''}`; const quoted = `


${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${escapeHtml(it.from_address || '')}:

`; - onCompose({ to: it.from_address || '', subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply')); + onCompose({ to: extractEmail(it.from_address), subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply')); } : undefined; diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index 18b42934..c68266d5 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -220,7 +220,7 @@ export const emailService = { const response = await api.post('/admin/email/incoming-config/poll', {}); return response.data; }, - async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted' } = {}): Promise { + async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted'; q?: string } = {}): Promise { const response = await api.get('/admin/email/received', { params }); return response.data; },