(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 */}
+
+
+ ) : (
+
+ {t('messages.noBody', 'This message was sent before body capture was added, so no preview is available.')}
+
+ )}
+
+ {d.attachments.length > 0 && (
+
+
+ {d.attachments.length} {t('messages.attachments', 'attachment(s)')}
+
+
+ {d.attachments.map((a, i) => (
+
+
+ {a.filename}
+
+ {t('messages.notArchived', 'not archived yet')}
+
+
+ ))}
+
+
+ )}
+ >
+);
+
+const ReceivedDetail: React.FC<{
+ item: ReceivedEmail;
+ onViewDoc: (id: number) => void;
+ onOpenAccounting: () => void;
+ t: (k: string, d?: string) => string;
+}> = ({ item, onViewDoc, onOpenAccounting, t }) => (
+ <>
+
+ {item.subject || t('messages.noSubject', '(no subject)')}
+
+
+
+ {t('messages.from', 'from')} {item.from_address || '—'}
+ {' · '}{t('messages.to', 'to')} rechnungen@
+
+
{fmt(item.received_at)}
+
+
+
+ {t('messages.inboundBodyPending', 'Full message bodies are captured from the next phase. For now this shows the envelope and any attached document.')}
+
+
+ {item.inbound_document_id != null && (
+
+
+
+
+ )}
+ {item.error && (
+ {item.error}
+ )}
+ >
+);
+
+// ─────────────────────────────────────────────────────────────── toolbar ──
+const Toolbar: React.FC<{ isAcct: boolean; readonly: boolean; t: (k: string, d?: string) => string }> = ({ isAcct, readonly, t }) => {
+ const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean }> = ({ icon: Icon, label, accent }) => (
+
+ );
+ return (
+
+ {!readonly && }
+ {!readonly && }
+
+
+ {isAcct ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+// ─────────────────────────────────────────────────────────────── pdf modal ──
+const PdfModal: React.FC<{ docId: number; onClose: () => void; t: (k: string, d?: string) => string }> = ({ docId, onClose, t }) => {
+ const [page, setPage] = useState(1);
+ const [url, setUrl] = useState(null);
+ const [err, setErr] = useState(false);
+
+ useEffect(() => {
+ let revoked: string | null = null;
+ let cancelled = false;
+ setErr(false);
+ setUrl(null);
+ accountingService.getInboundPageBlob(docId, page)
+ .then((blob) => {
+ if (cancelled) return;
+ const u = URL.createObjectURL(blob);
+ revoked = u;
+ setUrl(u);
+ })
+ .catch(() => { if (!cancelled) setErr(true); });
+ return () => { cancelled = true; if (revoked) URL.revokeObjectURL(revoked); };
+ }, [docId, page]);
+
+ useEffect(() => {
+ const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', h);
+ return () => window.removeEventListener('keydown', h);
+ }, [onClose]);
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
{t('messages.document', 'Document')}
+
+
+ {page}
+
+
+
+
+
+ {err ? (
+
{t('messages.previewUnavailable', 'Preview unavailable')}
+ ) : url ? (
+

+ ) : (
+
+ )}
+
+
+ {t('messages.rasterNote', 'Server-rendered preview — the raw file never reaches the browser.')}
+
+
+
+ );
+};
+
+export default MessagesPage;
diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts
index 7524c3c1..8d72452b 100644
--- a/frontend/src/services/email.service.ts
+++ b/frontend/src/services/email.service.ts
@@ -22,6 +22,15 @@ export interface EmailQueueListResponse {
pagination: { total: number; page: number; pageSize: number; totalPages: number };
}
+/** Single sent/queued email including its rendered body — Messages reading pane. */
+export interface EmailQueueDetail extends EmailQueueItem {
+ /** Exact HTML that was sent (migration 119); null for pre-migration rows. */
+ renderedHtml: string | null;
+ cc: string | null;
+ /** Attachment filenames only — disk paths are never exposed. */
+ attachments: { filename: string; contentType: string | null }[];
+}
+
export interface EmailConfig {
smtp_host: string;
smtp_port: number;
@@ -207,6 +216,12 @@ export const emailService = {
return response.data;
},
+ /** Single sent email with its rendered body + attachment filenames. */
+ async getQueueItem(id: number): Promise {
+ const response = await api.get(`/admin/email/queue/${id}`);
+ return response.data;
+ },
+
// Get all email templates
async getTemplates(): Promise {
const response = await api.get('/admin/email/templates');