From 26eeb7619703ffe0b29653e409beecd30263bcb0 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:15:41 +0200 Subject: [PATCH] feat(messages): Phase 1 read-only Messages viewer (email client shell) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New admin "Messages" page — a three-pane mail viewer over the mail picpeak already stores, feature-flagged behind `messaging` (default off): - Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@) / Automated (no-reply@), matching the agreed IA. - Automated + All Sent = email_queue (listQueue); Accounting + All Inbox = received_emails (listReceived). Customers folders show an explanatory empty state pending the hello@ mailbox (Phase 2). - Reading pane renders the sent body from rendered_html (migration 119) in a sandboxed iframe; new GET /admin/email/queue/:id returns body + cc + attachment filenames (disk paths never exposed). - Received supplier invoices: envelope + rasterized PDF viewer reusing the accounting inbound blob endpoint, plus "Open in Accounting inbox". - Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice / Book-as-expense-Re-bill) present but disabled — wired in later phases. Reuses email.service, accounting inbound blob endpoint, RequireFeature + PermissionGate (email.view), Tailwind dark: theming. No schema change. --- backend/src/routes/adminEmail.js | 51 ++ frontend/src/App.tsx | 8 + .../src/components/admin/AdminSidebar.tsx | 2 + frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + .../src/pages/admin/messages/MessagesPage.tsx | 516 ++++++++++++++++++ frontend/src/services/email.service.ts | 15 + 7 files changed, 594 insertions(+) create mode 100644 frontend/src/pages/admin/messages/MessagesPage.tsx diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 5fa85ca1..ae18a9eb 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -518,6 +518,57 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ } }); +// Single queued/sent email WITH its rendered body — powers the Messages +// reading pane. `rendered_html` is the exact HTML that was sent (migration +// 119); rows sent before that migration have none. Attachment disk paths in +// `email_data` are never exposed — only the filenames, so the pane can list +// attachments without leaking storage paths (same PII posture as the list). +router.get('/queue/:id', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' }); + const row = await db('email_queue') + .leftJoin('events', 'events.id', 'email_queue.event_id') + .select('email_queue.*', 'events.event_name as event_name', 'events.slug as event_slug') + .where('email_queue.id', id) + .first(); + if (!row) return res.status(404).json({ error: 'Email not found' }); + + let cc = null; + let attachments = []; + try { + const data = row.email_data ? JSON.parse(row.email_data) : {}; + if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc); + if (Array.isArray(data.attachments)) { + attachments = data.attachments + .filter((a) => a && a.filename) + .map((a) => ({ filename: a.filename, contentType: a.contentType || null })); + } + } catch (_) { /* malformed email_data → no cc/attachments, still return the body */ } + + res.json({ + id: row.id, + recipientEmail: row.recipient_email, + emailType: row.email_type, + status: row.status, + createdAt: row.created_at, + scheduledAt: row.scheduled_at, + sentAt: row.sent_at, + errorMessage: row.error_message, + retryCount: row.retry_count, + eventId: row.event_id, + eventName: row.event_name || null, + eventSlug: row.event_slug || null, + renderedHtml: row.rendered_html || null, + cc, + attachments, + }); + } catch (error) { + logger.error('Get email queue item error:', error); + res.status(500).json({ error: 'Failed to load email', details: error.message }); + } +}); + // Helper: parse variables JSON safely function parseVariables(template) { try { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27629a4d..854369a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage'; // (carved into its own chunk in vite.config.ts) doesn't ship with the // main app. Only pages that visit /admin/clients/calendar fetch it. const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage }))); +const MessagesPage = lazy(() => import('./pages/admin/messages/MessagesPage').then((m) => ({ default: m.MessagesPage }))); import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; import { ContractResponsePage } from './pages/public/ContractResponsePage'; import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage'; @@ -241,6 +242,13 @@ function App() { }> } /> + }> + }> + + + } /> + {/* Clients section (#354 follow-up). Parent route gated by the top-level `clients` flag — when off the sidebar entry is hidden and every /admin/clients/* diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 6a18cd09..3d154f9a 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -11,6 +11,7 @@ import { Users, Briefcase, Landmark, + Mail, Workflow, PanelLeftClose, PanelLeftOpen, @@ -64,6 +65,7 @@ const navigation: NavItem[] = [ { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false }, { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, + { nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' }, { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' }, { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, { nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' }, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 971feefe..0d5d87ee 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -197,6 +197,7 @@ "navigation": { "dashboard": "Dashboard", "events": "Veranstaltungen", + "messages": "Nachrichten", "settings": "Einstellungen", "systemHealth": "Systemzustand", "archives": "Archive", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 93df80e8..482ba9af 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -198,6 +198,7 @@ "dashboard": "Dashboard", "events": "Events", "archives": "Archives", + "messages": "Messages", "settings": "Settings", "systemHealth": "System health", "eventTypes": "Event Types", diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx new file mode 100644 index 00000000..a2a35d45 --- /dev/null +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -0,0 +1,516 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { + Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip, + FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText, + Link2, X, ChevronLeft, ChevronRight, Mail, type LucideIcon, +} from 'lucide-react'; +import { emailService, type ReceivedEmail } from '../../../services/email.service'; +import { accountingService } from '../../../services/accounting.service'; +import { Loading } from '../../../components/common'; + +/** + * Admin "Messages" — Phase 1 read-only viewer over the mail picpeak already + * has: the Automated stream (email_queue, incl. rendered bodies from migration + * 119) and the Accounting inbox (received_emails / supplier invoices). The + * Customers (hello@) mailbox and reply/compose land in later phases; those + * folders render an explanatory empty state so the full IA is visible now. + */ + +type FolderSrc = 'queue' | 'received' | 'empty'; +interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; note?: string; } +interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; } + +type Selection = + | { kind: 'queue'; id: number } + | { kind: 'received'; item: ReceivedEmail } + | null; + +const TYPE_LABELS: Record = { + invoice_sent: 'Invoice sent', + invoice_reminder_first: 'Payment reminder', + invoice_reminder_second: 'Payment reminder', + invoice_reminder_final: 'Final reminder', + invoice_payment_check: 'Payment check', + invoice_collections_handoff: 'Collections handoff', + invoice_paid_admin_notification: 'Payment received', + expiration_warning: 'Gallery expiring', + gallery_expired: 'Gallery expired', + quote_sent: 'Quote sent', + contract_sent: 'Contract sent', +}; +const friendlyType = (t: string) => + TYPE_LABELS[t] || t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + +const fmt = (s?: string | null) => + s ? new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : ''; + +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', + pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', + error: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', +}; + +export const MessagesPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [activeFolder, setActiveFolder] = useState('auto-sent'); + const [selection, setSelection] = useState(null); + const [pdfDocId, setPdfDocId] = useState(null); + + const queueQuery = useQuery({ + queryKey: ['messages', 'queue'], + queryFn: () => emailService.listQueue({ pageSize: 100 }), + refetchInterval: 60000, + }); + const receivedQuery = useQuery({ + queryKey: ['messages', 'received'], + queryFn: () => emailService.listReceived({ pageSize: 100 }), + refetchInterval: 60000, + }); + + const queueTotal = queueQuery.data?.pagination.total; + const receivedTotal = receivedQuery.data?.pagination.total; + + const accounts: Account[] = useMemo(() => [ + { id: 'all', name: t('messages.account.all', 'All mail'), color: '#64748b', folders: [ + { id: 'all-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received' }, + { id: 'all-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue' }, + ] }, + { id: 'cust', name: t('messages.account.customers', 'Customers'), addr: 'hello@', color: '#2563c9', folders: [ + { id: 'cust-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'empty', + note: t('messages.empty.customerInbox', 'Connect the hello@ mailbox to see customer replies here (next phase).') }, + { id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'empty', + note: t('messages.empty.customerSent', 'Your hand-written replies will appear here once compose ships.') }, + ] }, + { id: 'acct', name: t('messages.account.accounting', 'Accounting'), addr: 'rechnungen@', color: '#12876a', folders: [ + { id: 'acct-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received' }, + ] }, + { id: 'auto', name: t('messages.account.automated', 'Automated'), addr: 'no-reply@', color: '#7a52d6', folders: [ + { id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue' }, + ] }, + ], [t]); + + const folder = useMemo(() => { + for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f }; + return { a: accounts[0], f: accounts[0].folders[0] }; + }, [accounts, activeFolder]); + + const countFor = (src: FolderSrc) => + src === 'queue' ? queueTotal : src === 'received' ? receivedTotal : undefined; + + return ( +
+
+
+

+ + {t('messages.title', 'Messages')} +

+

+ {t('messages.subtitle', 'Sent, automated and incoming mail — one place.')} +

+
+
+ +
+ {/* ── account tree ── */} + + + {/* ── message list ── */} +
+
+
{folder.f.name}
+
+ {folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))} +
+
+
+ +
+
+ + {/* ── reading pane ── */} +
+ navigate('/admin/accounting/inbox')} + t={t} + /> +
+
+ + {pdfDocId != null && setPdfDocId(null)} t={t} />} +
+ ); +}; + +// ─────────────────────────────────────────────────────────── message list ── +const MessageList: React.FC<{ + folder: Folder; + queue?: import('../../../services/email.service').EmailQueueItem[]; + received?: ReceivedEmail[]; + loading: boolean; + selection: Selection; + onSelect: (s: Selection) => void; + t: (k: string, d?: string) => string; +}> = ({ folder, queue, received, loading, selection, onSelect, t }) => { + if (folder.src === 'empty') { + return ( +
+ + {folder.note} +
+ ); + } + if (loading) return
; + + const rows = + folder.src === 'queue' + ? (queue || []).map((m) => ({ + key: `q${m.id}`, + onClick: () => onSelect({ kind: 'queue', id: m.id }), + active: selection?.kind === 'queue' && selection.id === m.id, + who: m.recipientEmail, + subject: friendlyType(m.emailType), + when: fmt(m.sentAt || m.createdAt), + status: m.status, + attach: 0, + })) + : (received || []).map((m) => ({ + key: `r${m.id}`, + onClick: () => onSelect({ kind: 'received', item: m }), + active: selection?.kind === 'received' && selection.item.id === m.id, + who: m.from_address || '—', + subject: m.subject || t('messages.noSubject', '(no subject)'), + when: fmt(m.received_at), + status: m.status, + attach: m.attachment_count, + })); + + if (rows.length === 0) { + return
{t('messages.noMessages', 'No messages')}
; + } + + return ( +
    + {rows.map((r) => ( +
  • + +
  • + ))} +
+ ); +}; + +// ─────────────────────────────────────────────────────────── reading pane ── +const ReadingPane: React.FC<{ + selection: Selection; + account: Account; + onViewDoc: (id: number) => void; + onOpenAccounting: () => void; + t: (k: string, d?: string) => string; +}> = ({ selection, account, onViewDoc, onOpenAccounting, t }) => { + const detailQuery = useQuery({ + queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null], + queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id), + enabled: selection?.kind === 'queue', + }); + + if (!selection) { + return ( +
+
+ +
{t('messages.selectPrompt', 'Select a message to read')}
+
+
+ ); + } + + const isAcct = account.id === 'acct' || (selection.kind === 'received'); + return ( +
+ +
+ {selection.kind === 'queue' ? ( + detailQuery.isLoading ? : detailQuery.data ? ( + + ) : ( +
{t('messages.loadError', 'Could not load this message.')}
+ ) + ) : ( + + )} +
+
+ ); +}; + +const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; t: (k: string, d?: string) => string }> = ({ d, t }) => ( + <> +

+ {friendlyType(d.emailType)} +

+
+
+ {t('messages.from', 'from')} no-reply@ · {t('messages.to', 'to')}{' '} + {d.recipientEmail} +
+ {d.cc &&
cc {d.cc}
} +
{fmt(d.sentAt || d.createdAt)}
+
+ + {d.renderedHtml ? ( +
+ {/* rendered_html is our own template output — sandboxed, scripts blocked */} +