(null);
+ const [composer, setComposer] = useState<{ init: ComposerInit; title?: string } | null>(null);
+
+ const queueQuery = useQuery({
+ queryKey: ['messages', 'queue'],
+ queryFn: () => emailService.listQueue({ pageSize: 100 }),
+ refetchInterval: 60000,
+ });
+ const acctQuery = useQuery({
+ queryKey: ['messages', 'received', 'accounting'],
+ queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100 }),
+ refetchInterval: 60000,
+ });
+ const custQuery = useQuery({
+ queryKey: ['messages', 'received', 'customers'],
+ queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100 }),
+ refetchInterval: 60000,
+ });
+
+ const queueTotal = queueQuery.data?.pagination.total;
+ const acctTotal = acctQuery.data?.pagination.total;
+ const custTotal = custQuery.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: 'received', account: 'customers' },
+ { id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'manual' },
+ ] },
+ { 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', account: 'accounting' },
+ ] },
+ { 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', origin: 'system' },
+ ] },
+ ], [t]);
+
+ // Sent stream is split client-side by origin: system (Automated) vs manual
+ // (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system.
+ const queueItemsAll = queueQuery.data?.items || [];
+ const queueFor = (origin?: 'system' | 'manual') =>
+ origin === 'manual' ? queueItemsAll.filter((i) => i.origin === 'manual')
+ : origin === 'system' ? queueItemsAll.filter((i) => i.origin !== 'manual')
+ : queueItemsAll;
+
+ 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 = (f: Folder): number | undefined => {
+ if (f.src === 'queue') return f.origin ? queueFor(f.origin).length : queueTotal;
+ if (f.src === 'received') {
+ if (f.account === 'customers') return custTotal;
+ if (f.account === 'accounting') return acctTotal;
+ return (acctTotal || 0) + (custTotal || 0);
+ }
+ return undefined;
+ };
+
+ // Which received rows feed the active folder (customer / accounting / union).
+ const receivedItems = useMemo(() => {
+ if (folder.f.src !== 'received') return undefined;
+ const a = acctQuery.data?.items || [];
+ const c = custQuery.data?.items || [];
+ if (folder.f.account === 'customers') return c;
+ if (folder.f.account === 'accounting') return a;
+ return [...a, ...c].sort((x, y) => (y.received_at || '').localeCompare(x.received_at || ''));
+ }, [folder, acctQuery.data, custQuery.data]);
+
+ const receivedLoading = folder.f.account === 'customers'
+ ? custQuery.isLoading
+ : folder.f.account === 'accounting'
+ ? acctQuery.isLoading
+ : acctQuery.isLoading || custQuery.isLoading;
+
+ return (
+
+
+
+
+
+ {t('messages.title', 'Messages')}
+
+
+ {t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}
+
+
+
+
+
+ {/* ── account tree ── */}
+
+ {accounts.map((a) => (
+
+
+
+ {a.name}
+ {a.addr && {a.addr} }
+
+
+ {a.folders.map((f) => {
+ const c = countFor(f);
+ const active = f.id === activeFolder;
+ return (
+ { setActiveFolder(f.id); setSelection(null); }}
+ className={`flex items-center gap-2 pl-7 pr-2 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
+ active
+ ? 'bg-blue-50 dark:bg-blue-900/25 text-blue-800 dark:text-blue-200 font-semibold ring-1 ring-inset ring-blue-200 dark:ring-blue-800'
+ : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60'
+ }`}
+ >
+
+ {f.name}
+ {typeof c === 'number' && c > 0 && (
+ {c}
+ )}
+
+ );
+ })}
+
+
+ ))}
+
+
+ {/* ── 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')}
+ onCompose={(init, title) => setComposer({ init, title })}
+ t={t}
+ />
+
+
+
+ {pdfDocId != null &&
setPdfDocId(null)} t={t} />}
+ {composer && (
+ setComposer(null)}
+ onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
+ 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) => (
+
+
+
+ {r.who}
+ {r.when}
+
+ {r.subject}
+
+
+ {r.status}
+
+ {r.attach > 0 && (
+
+ {r.attach}
+
+ )}
+
+
+
+ ))}
+
+ );
+};
+
+// ─────────────────────────────────────────────────────────── reading pane ──
+const ReadingPane: React.FC<{
+ selection: Selection;
+ account: Account;
+ lang: string;
+ onViewDoc: (id: number) => void;
+ onOpenAccounting: () => void;
+ onCompose: (init: ComposerInit, title?: string) => void;
+ t: (k: string, d?: string) => string;
+}> = ({ selection, account, lang, onViewDoc, onOpenAccounting, onCompose, 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')}
+
+
+ );
+ }
+
+ // Accounting toolbar only for the rechnungen@ stream; customer mail (inbound
+ // or the automated/sent streams) gets the CRM action set.
+ const isAcct = selection.kind === 'received'
+ ? selection.item.account_key !== 'customers'
+ : account.id === 'acct';
+
+ const recipient = 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
+ ? () => {
+ 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)}, ${it.from_address}:
`;
+ onCompose({ to: it.from_address || '', subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply'));
+ }
+ : undefined;
+
+ // Create-from-template opens the composer with the rendered template loaded
+ // (freely editable); empty key = blank compose (gallery has no send template).
+ const onCreate = !isAcct
+ ? async (key: string, label: string) => {
+ if (!key) { onCompose({ to: recipient, subject: '', html: '' }, label); return; }
+ try {
+ const p = await emailService.previewTemplate(key, { customer_name: recipient.split('@')[0] || '' }, lang || 'en');
+ onCompose({ to: recipient, subject: p.subject, html: p.body_html }, label);
+ } catch {
+ onCompose({ to: recipient, subject: label, html: '' }, label);
+ }
+ }
+ : undefined;
+
+ 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 }) => {
+ const detail = useQuery({
+ queryKey: ['messages', 'received', 'item', item.id],
+ queryFn: () => emailService.getReceivedItem(item.id),
+ });
+ const toAddr = detail.data?.to_address || item.to_address
+ || (item.account_key === 'customers' ? 'hello@' : 'rechnungen@');
+ return (
+ <>
+
+ {item.subject || t('messages.noSubject', '(no subject)')}
+
+
+
+ {t('messages.from', 'from')} {item.from_address || '—'}
+ {' · '}{t('messages.to', 'to')} {toAddr}
+
+
{fmt(item.received_at)}
+
+
+ {detail.isLoading ? (
+
+ ) : detail.data?.body_html ? (
+
+ {/* Sanitized server-side; rendered with a strict (script-less, no
+ same-origin) sandbox as a second layer against untrusted mail. */}
+
+
+ ) : detail.data?.body_text ? (
+ {detail.data.body_text}
+ ) : (
+
+ {t('messages.noInboundBody', 'No message body was captured for this email.')}
+
+ )}
+
+ {item.inbound_document_id != null && (
+
+ onViewDoc(item.inbound_document_id as number)}
+ className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium"
+ >
+ {t('messages.viewDocument', 'View document')}
+
+
+ {t('messages.openInAccounting', 'Open in Accounting inbox')}
+
+
+ )}
+ {item.error && (
+ {item.error}
+ )}
+ >
+ );
+};
+
+// ─────────────────────────────────────────────────────────────── toolbar ──
+const Toolbar: React.FC<{
+ isAcct: boolean;
+ onReply?: () => void;
+ onCreate?: (key: string, label: string) => void;
+ t: (k: string, d?: string) => string;
+}> = ({ isAcct, onReply, onCreate, t }) => {
+ const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
+ const enabled = !!onClick;
+ return (
+
+ {label}
+
+ );
+ };
+ const create = (key: string, labelKey: string, fallback: string) =>
+ onCreate ? () => onCreate(key, t(labelKey, fallback)) : undefined;
+ return (
+
+
+
+
+
+ {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')}
+
+ setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}
+ className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-40">
+
+
+ {page}
+ setPage((p) => p + 1)}
+ className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
+
+
+
+
+
+
+
+
+ {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..640cda41 100644
--- a/frontend/src/services/email.service.ts
+++ b/frontend/src/services/email.service.ts
@@ -15,6 +15,8 @@ export interface EmailQueueItem {
eventId: number | null;
eventName: string | null;
eventSlug: string | null;
+ /** 'system' = app-generated (Automated), 'manual' = admin-composed (Customers Sent). */
+ origin?: 'system' | 'manual';
}
export interface EmailQueueListResponse {
@@ -22,6 +24,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;
@@ -116,7 +127,9 @@ export interface ImapPollResult {
export interface ReceivedEmail {
id: number;
message_id: string | null;
+ account_key?: string | null;
from_address: string | null;
+ to_address?: string | null;
subject: string | null;
received_at: string | null;
attachment_count: number;
@@ -125,11 +138,31 @@ export interface ReceivedEmail {
error: string | null;
}
+/** Single received email including its captured, server-sanitized body. */
+export interface ReceivedEmailDetail extends ReceivedEmail {
+ body_html: string | null;
+ body_text: string | null;
+}
+
export interface ReceivedEmailsResponse {
items: ReceivedEmail[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}
+/** An additional inbound mailbox beyond the primary accounting IMAP. */
+export interface MailAccount {
+ id?: number;
+ account_key: string;
+ label?: string | null;
+ imap_host?: string | null;
+ imap_port?: number;
+ imap_secure?: boolean;
+ imap_user?: string | null;
+ imap_pass?: string;
+ imap_folder?: string;
+ enabled?: boolean;
+}
+
export const emailService = {
// Get email configuration
async getConfig(): Promise {
@@ -172,10 +205,26 @@ export const emailService = {
const response = await api.post('/admin/email/incoming-config/poll', {});
return response.data;
},
- async listReceived(params: { page?: number; pageSize?: number } = {}): Promise {
+ async listReceived(params: { page?: number; pageSize?: number; account?: string } = {}): Promise {
const response = await api.get('/admin/email/received', { params });
return response.data;
},
+ async getReceivedItem(id: number): Promise {
+ const response = await api.get(`/admin/email/received/${id}`);
+ return response.data;
+ },
+ // Additional inbound mailboxes (e.g. the customer hello@ box).
+ async listMailAccounts(): Promise {
+ const response = await api.get<{ items: MailAccount[] }>('/admin/email/accounts');
+ return response.data.items;
+ },
+ async saveMailAccount(account: MailAccount): Promise {
+ await api.post('/admin/email/accounts', account);
+ },
+ async testMailAccount(account: Partial): Promise {
+ const response = await api.post('/admin/email/accounts/test', account);
+ return response.data;
+ },
// Test email configuration
async testEmail(testEmail: string): Promise {
@@ -197,6 +246,7 @@ export const emailService = {
async listQueue(params: {
status?: EmailQueueStatus;
emailType?: string;
+ origin?: 'system' | 'manual';
q?: string;
from?: string;
to?: string;
@@ -207,6 +257,17 @@ 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;
+ },
+
+ /** Send a human-composed (edited) email — reply or document message. */
+ async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number }): Promise {
+ await api.post('/admin/email/send', payload);
+ },
+
// Get all email templates
async getTemplates(): Promise {
const response = await api.get('/admin/email/templates');