feat(messages): Phase 1 read-only Messages viewer (email client shell)
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.
This commit is contained in:
@@ -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
|
// Helper: parse variables JSON safely
|
||||||
function parseVariables(template) {
|
function parseVariables(template) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
|
// (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.
|
// 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 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 { QuoteResponsePage } from './pages/public/QuoteResponsePage';
|
||||||
import { ContractResponsePage } from './pages/public/ContractResponsePage';
|
import { ContractResponsePage } from './pages/public/ContractResponsePage';
|
||||||
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
|
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
|
||||||
@@ -241,6 +242,13 @@ function App() {
|
|||||||
<Route element={<RequireFeature flag="userManagement" />}>
|
<Route element={<RequireFeature flag="userManagement" />}>
|
||||||
<Route path="users" element={<UserManagementPage />} />
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route element={<RequireFeature flag="messaging" />}>
|
||||||
|
<Route path="messages" element={
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<MessagesPage />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
</Route>
|
||||||
{/* Clients section (#354 follow-up). Parent route
|
{/* Clients section (#354 follow-up). Parent route
|
||||||
gated by the top-level `clients` flag — when off
|
gated by the top-level `clients` flag — when off
|
||||||
the sidebar entry is hidden and every /admin/clients/*
|
the sidebar entry is hidden and every /admin/clients/*
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Landmark,
|
Landmark,
|
||||||
|
Mail,
|
||||||
Workflow,
|
Workflow,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
@@ -64,6 +65,7 @@ const navigation: NavItem[] = [
|
|||||||
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
|
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
|
||||||
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
|
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
|
||||||
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.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: '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.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||||
{ nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
|
{ nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
"navigation": {
|
"navigation": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"events": "Veranstaltungen",
|
"events": "Veranstaltungen",
|
||||||
|
"messages": "Nachrichten",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"systemHealth": "Systemzustand",
|
"systemHealth": "Systemzustand",
|
||||||
"archives": "Archive",
|
"archives": "Archive",
|
||||||
|
|||||||
@@ -198,6 +198,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"archives": "Archives",
|
"archives": "Archives",
|
||||||
|
"messages": "Messages",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"systemHealth": "System health",
|
"systemHealth": "System health",
|
||||||
"eventTypes": "Event Types",
|
"eventTypes": "Event Types",
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<Selection>(null);
|
||||||
|
const [pdfDocId, setPdfDocId] = useState<number | null>(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 (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
|
<Mail className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
|
||||||
|
{t('messages.title', 'Messages')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||||
|
{t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white dark:bg-neutral-900">
|
||||||
|
{/* ── account tree ── */}
|
||||||
|
<nav className="w-56 flex-none border-r border-neutral-200 dark:border-neutral-800 overflow-y-auto p-2 bg-neutral-50 dark:bg-neutral-950/40">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div key={a.id} className="mb-1.5">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||||
|
<span className="w-2 h-2 rounded-full flex-none" style={{ background: a.color }} />
|
||||||
|
<span>{a.name}</span>
|
||||||
|
{a.addr && <span className="ml-auto text-[11px] font-medium font-mono text-neutral-400 dark:text-neutral-500">{a.addr}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{a.folders.map((f) => {
|
||||||
|
const c = countFor(f.src);
|
||||||
|
const active = f.id === activeFolder;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => { 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.icon className="w-4 h-4 opacity-80" />
|
||||||
|
<span>{f.name}</span>
|
||||||
|
{typeof c === 'number' && c > 0 && (
|
||||||
|
<span className={`ml-auto tabular-nums text-xs ${active ? 'text-blue-600 dark:text-blue-300' : 'text-neutral-400'}`}>{c}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── message list ── */}
|
||||||
|
<section className="w-[22rem] flex-none flex flex-col min-h-0 border-r border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||||
|
<div className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{folder.f.name}</div>
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
|
{folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<MessageList
|
||||||
|
folder={folder.f}
|
||||||
|
queue={queueQuery.data?.items}
|
||||||
|
received={receivedQuery.data?.items}
|
||||||
|
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedQuery.isLoading : false}
|
||||||
|
selection={selection}
|
||||||
|
onSelect={setSelection}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── reading pane ── */}
|
||||||
|
<section className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
|
<ReadingPane
|
||||||
|
selection={selection}
|
||||||
|
account={folder.a}
|
||||||
|
onViewDoc={setPdfDocId}
|
||||||
|
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pdfDocId != null && <PdfModal docId={pdfDocId} onClose={() => setPdfDocId(null)} t={t} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────── 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 (
|
||||||
|
<div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
<Inbox className="w-8 h-8 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||||
|
{folder.note}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loading) return <div className="p-6"><Loading /></div>;
|
||||||
|
|
||||||
|
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 <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">{t('messages.noMessages', 'No messages')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.key}>
|
||||||
|
<button
|
||||||
|
onClick={r.onClick}
|
||||||
|
className={`w-full text-left px-4 py-3 border-b border-neutral-100 dark:border-neutral-800/70 border-l-[3px] transition-colors ${
|
||||||
|
r.active
|
||||||
|
? 'border-l-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-l-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-[13.5px] text-neutral-800 dark:text-neutral-100 truncate">{r.who}</span>
|
||||||
|
<span className="ml-auto text-[11px] text-neutral-400 tabular-nums whitespace-nowrap">{r.when}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] text-neutral-600 dark:text-neutral-300 truncate mt-0.5">{r.subject}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<span className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-full ${STATUS_STYLES[r.status] || 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300'}`}>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
{r.attach > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-neutral-400">
|
||||||
|
<Paperclip className="w-3 h-3" />{r.attach}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────── 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 (
|
||||||
|
<div className="flex-1 grid place-items-center text-center text-neutral-400 dark:text-neutral-500 p-10">
|
||||||
|
<div>
|
||||||
|
<Mail className="w-9 h-9 mx-auto mb-3 text-neutral-300 dark:text-neutral-700" />
|
||||||
|
<div className="text-sm">{t('messages.selectPrompt', 'Select a message to read')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAcct = account.id === 'acct' || (selection.kind === 'received');
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
|
<Toolbar isAcct={isAcct} readonly={selection.kind === 'queue'} t={t} />
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{selection.kind === 'queue' ? (
|
||||||
|
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
||||||
|
<QueueDetail d={detailQuery.data} t={t} />
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-neutral-500">{t('messages.loadError', 'Could not load this message.')}</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ReceivedDetail item={selection.item} onViewDoc={onViewDoc} onOpenAccounting={onOpenAccounting} t={t} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; t: (k: string, d?: string) => string }> = ({ d, t }) => (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||||
|
{friendlyType(d.emailType)}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
|
||||||
|
<div className="text-neutral-600 dark:text-neutral-300">
|
||||||
|
{t('messages.from', 'from')} <span className="font-mono text-xs">no-reply@</span> · {t('messages.to', 'to')}{' '}
|
||||||
|
<span className="font-semibold text-neutral-800 dark:text-neutral-100">{d.recipientEmail}</span>
|
||||||
|
</div>
|
||||||
|
{d.cc && <div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">cc {d.cc}</div>}
|
||||||
|
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(d.sentAt || d.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{d.renderedHtml ? (
|
||||||
|
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '52vh' }}>
|
||||||
|
{/* rendered_html is our own template output — sandboxed, scripts blocked */}
|
||||||
|
<iframe title="Email body" sandbox="allow-same-origin" srcDoc={d.renderedHtml} className="w-full h-full border-0" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||||
|
{t('messages.noBody', 'This message was sent before body capture was added, so no preview is available.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{d.attachments.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
|
||||||
|
{d.attachments.length} {t('messages.attachments', 'attachment(s)')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 max-w-md">
|
||||||
|
{d.attachments.map((a, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/40">
|
||||||
|
<FileText className="w-5 h-5 text-red-500 flex-none" />
|
||||||
|
<span className="text-[13.5px] font-medium text-neutral-800 dark:text-neutral-100 truncate">{a.filename}</span>
|
||||||
|
<span className="ml-auto text-[11px] text-neutral-400" title={t('messages.sentAttachHint', 'Sent attachments are not archived yet — Phase 2.')}>
|
||||||
|
{t('messages.notArchived', 'not archived yet')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ReceivedDetail: React.FC<{
|
||||||
|
item: ReceivedEmail;
|
||||||
|
onViewDoc: (id: number) => void;
|
||||||
|
onOpenAccounting: () => void;
|
||||||
|
t: (k: string, d?: string) => string;
|
||||||
|
}> = ({ item, onViewDoc, onOpenAccounting, t }) => (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||||
|
{item.subject || t('messages.noSubject', '(no subject)')}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
|
||||||
|
<div className="text-neutral-600 dark:text-neutral-300">
|
||||||
|
{t('messages.from', 'from')} <span className="font-semibold text-neutral-800 dark:text-neutral-100">{item.from_address || '—'}</span>
|
||||||
|
{' · '}{t('messages.to', 'to')} <span className="font-mono text-xs">rechnungen@</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(item.received_at)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||||
|
{t('messages.inboundBodyPending', 'Full message bodies are captured from the next phase. For now this shows the envelope and any attached document.')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.inbound_document_id != null && (
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />{t('messages.viewDocument', 'View document')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onOpenAccounting}
|
||||||
|
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Link2 className="w-4 h-4" />{t('messages.openInAccounting', 'Open in Accounting inbox')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.error && (
|
||||||
|
<div className="mt-4 text-sm text-red-600 dark:text-red-400">{item.error}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────── 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 }) => (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
title={t('messages.soon', 'Available in a later phase')}
|
||||||
|
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-lg text-[13px] font-medium cursor-not-allowed opacity-50 ${
|
||||||
|
accent ? 'text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-200 dark:ring-blue-800' : 'text-neutral-600 dark:text-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-[15px] h-[15px]" />{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap px-3 py-2 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||||
|
{!readonly && <Tb icon={Reply} label={t('messages.reply', 'Reply')} />}
|
||||||
|
{!readonly && <Tb icon={ReplyAll} label={t('messages.replyAll', 'Reply all')} />}
|
||||||
|
<Tb icon={Forward} label={t('messages.forward', 'Forward')} />
|
||||||
|
<span className="w-px h-5 bg-neutral-200 dark:bg-neutral-700 mx-1" />
|
||||||
|
{isAcct ? (
|
||||||
|
<>
|
||||||
|
<Tb icon={ReceiptText} label={t('messages.bookExpense', 'Book as expense')} accent />
|
||||||
|
<Tb icon={Forward} label={t('messages.rebill', 'Re-bill to client')} accent />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent />
|
||||||
|
<Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent />
|
||||||
|
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent />
|
||||||
|
<Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Tb icon={Archive} label={t('messages.archive', 'Archive')} />
|
||||||
|
<Tb icon={Trash2} label={t('messages.delete', 'Delete')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────── 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<string | null>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-6" onClick={onClose}>
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(620px,94vw)] max-h-[90vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
<FileText className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">{t('messages.document', 'Document')}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button onClick={() => 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">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs tabular-nums text-neutral-500 w-6 text-center">{page}</span>
|
||||||
|
<button onClick={() => 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">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} aria-label={t('messages.close', 'Close')}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 ml-1">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto p-5 bg-neutral-100 dark:bg-neutral-800 grid place-items-center min-h-[240px]">
|
||||||
|
{err ? (
|
||||||
|
<div className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.previewUnavailable', 'Preview unavailable')}</div>
|
||||||
|
) : url ? (
|
||||||
|
<img src={url} alt="" className="max-w-full shadow-lg rounded" />
|
||||||
|
) : (
|
||||||
|
<Loading />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-[11px] text-neutral-400 py-2 border-t border-neutral-200 dark:border-neutral-800">
|
||||||
|
{t('messages.rasterNote', 'Server-rendered preview — the raw file never reaches the browser.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessagesPage;
|
||||||
@@ -22,6 +22,15 @@ export interface EmailQueueListResponse {
|
|||||||
pagination: { total: number; page: number; pageSize: number; totalPages: number };
|
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 {
|
export interface EmailConfig {
|
||||||
smtp_host: string;
|
smtp_host: string;
|
||||||
smtp_port: number;
|
smtp_port: number;
|
||||||
@@ -207,6 +216,12 @@ export const emailService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Single sent email with its rendered body + attachment filenames. */
|
||||||
|
async getQueueItem(id: number): Promise<EmailQueueDetail> {
|
||||||
|
const response = await api.get<EmailQueueDetail>(`/admin/email/queue/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get all email templates
|
// Get all email templates
|
||||||
async getTemplates(): Promise<EmailTemplate[]> {
|
async getTemplates(): Promise<EmailTemplate[]> {
|
||||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||||
|
|||||||
Reference in New Issue
Block a user