Merge pull request #769 from Luca-Timo/feat/messages-email-client

feat(messages): unified Messages email client (flag-gated, default off)
This commit is contained in:
Paul Nothaft
2026-07-08 12:38:58 +02:00
committed by GitHub
19 changed files with 2222 additions and 34 deletions
+8
View File
@@ -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() {
<Route element={<RequireFeature flag="userManagement" />}>
<Route path="users" element={<UserManagementPage />} />
</Route>
<Route element={<RequireFeature flag="messaging" />}>
<Route path="messages" element={
<Suspense fallback={<Loading />}>
<MessagesPage />
</Suspense>
} />
</Route>
{/* 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/*
@@ -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' },
@@ -0,0 +1,163 @@
/**
* Customer mailbox (hello@) configuration — a second inbound IMAP box beyond
* the accounting rechnungen@ one, stored in `mail_accounts` under the fixed
* account_key 'customers'. Its mail feeds Messages → Customers ▸ Inbox (body
* captured, attachments NOT routed to accounting). Shown when the `messaging`
* feature flag is on. Styled to match the Incoming Mail card.
*/
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Save, Server, User, Lock, Eye, EyeOff, PlugZap, Inbox } from 'lucide-react';
import { Button, Card, Input, Loading } from '../common';
import { emailService, type MailAccount } from '../../services/email.service';
import { useMutationWithToast, useModal } from '../../hooks';
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark';
const ACCOUNT_KEY = 'customers';
export const CustomerMailboxCard: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading } = useQuery({ queryKey: ['mail-accounts'], queryFn: () => emailService.listMailAccounts() });
const [cfg, setCfg] = useState<MailAccount>({ account_key: ACCOUNT_KEY, imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX', enabled: false });
const passwordVisibility = useModal();
useEffect(() => {
if (!data) return;
const row = data.find((a) => a.account_key === ACCOUNT_KEY);
if (row) setCfg({ ...row, imap_pass: row.imap_pass || '' });
}, [data]);
const set = (k: keyof MailAccount, v: any) => setCfg((c) => ({ ...c, [k]: v }));
const save = useMutationWithToast({
mutationFn: () => {
if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) {
return Promise.reject(new Error(t('email.customerMailbox.requiredFields', 'Host, port and username are required.')));
}
return emailService.saveMailAccount({ ...cfg, account_key: ACCOUNT_KEY, label: 'Customers' });
},
successMessage: t('email.customerMailbox.savedToast', 'Customer mailbox saved.'),
invalidateKeys: [['mail-accounts']],
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
const test = useMutationWithToast({
mutationFn: () => emailService.testMailAccount({ ...cfg, account_key: ACCOUNT_KEY }),
successMessage: (r) => t('email.customerMailbox.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }),
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.customerMailbox.testFailed', 'Connection failed.'),
});
if (isLoading) return <Loading />;
return (
<Card padding="md" className="mt-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Inbox className="w-5 h-5 text-neutral-400" />
{t('email.customerMailbox.title', 'Customer mailbox (hello@)')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('email.customerMailbox.subtitle', 'A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.')}
</p>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input type="checkbox" checked={!!cfg.enabled} onChange={(e) => set('enabled', e.target.checked)} />
{t('email.customerMailbox.enabled', 'Poll this mailbox every minute')}
</label>
<div>
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')} <span className="text-red-500">*</span></label>
<Input type="text" value={cfg.imap_host || ''} onChange={(e) => set('imap_host', e.target.value)} placeholder="imap.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>{t('email.incoming.port', 'Port')} <span className="text-red-500">*</span></label>
<Input type="number" value={cfg.imap_port ?? 993} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" />
</div>
<div>
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
<select className={selectCls} value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS')}</option>
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS')}</option>
</select>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.user', 'Username')} <span className="text-red-500">*</span></label>
<Input type="text" value={cfg.imap_user || ''} onChange={(e) => set('imap_user', e.target.value)} autoComplete="off" placeholder="[email protected]" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
</div>
<div>
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
<div className="relative">
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.imap_pass || ''} onChange={(e) => set('imap_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
<button type="button" onClick={passwordVisibility.toggle} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
{passwordVisibility.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
<Input type="text" value={cfg.imap_folder || 'INBOX'} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
</div>
<div className="pt-4 mt-1 border-t border-neutral-200 dark:border-neutral-700">
<div className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
{t('email.customerMailbox.outgoing', 'Outgoing (SMTP)')}
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 mb-3">
{t('email.customerMailbox.outgoingHint', 'Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.')}
</p>
<div className="space-y-4">
<div>
<label className={labelCls}>{t('email.customerMailbox.fromEmail', 'From address')}</label>
<Input type="text" value={cfg.from_email || ''} onChange={(e) => set('from_email', e.target.value)} placeholder="[email protected]" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
</div>
<div>
<label className={labelCls}>{t('email.customerMailbox.smtpHost', 'SMTP Host')}</label>
<Input type="text" value={cfg.smtp_host || ''} onChange={(e) => set('smtp_host', e.target.value)} placeholder="smtp.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>{t('email.incoming.port', 'Port')}</label>
<Input type="number" value={cfg.smtp_port ?? 587} onChange={(e) => set('smtp_port', parseInt(e.target.value, 10) || 0)} placeholder="587" />
</div>
<div>
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
<select className={selectCls} value={cfg.smtp_secure ? 'ssl' : 'starttls'} onChange={(e) => set('smtp_secure', e.target.value === 'ssl')}>
<option value="ssl">{t('email.customerMailbox.smtpSsl', 'SSL (465)')}</option>
<option value="starttls">{t('email.customerMailbox.smtpStarttls', 'STARTTLS (587)')}</option>
</select>
</div>
</div>
<div>
<label className={labelCls}>{t('email.customerMailbox.smtpUser', 'SMTP Username')}</label>
<Input type="text" value={cfg.smtp_user || ''} onChange={(e) => set('smtp_user', e.target.value)} autoComplete="off" placeholder="[email protected]" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
</div>
<div>
<label className={labelCls}>{t('email.customerMailbox.smtpPass', 'SMTP Password')}</label>
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.smtp_pass || ''} onChange={(e) => set('smtp_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => test.mutate()} isLoading={test.isPending} disabled={!cfg.imap_host || !cfg.imap_user} leftIcon={<PlugZap className="w-5 h-5" />} className="whitespace-nowrap">
{t('email.incoming.test', 'Test connection')}
</Button>
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
{t('email.customerMailbox.save', 'Save Customer Mailbox')}
</Button>
</div>
</div>
</Card>
);
};
export default CustomerMailboxCard;
@@ -235,15 +235,13 @@ export const FeaturesTab: React.FC = () => {
title={t('settings.features.messaging.title', 'Messaging')}
description={t(
'settings.features.messaging.description',
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place. Coming soon.',
'A unified Messages area: your sent + automated mail, the accounting inbox, and a customer mailbox (hello@) in one place — with reply and create-from-template composing. Configure the customer mailbox under Settings → Email; incoming mailboxes need the Incoming mail toggle too.',
)}
status="roadmap"
statusLabel={statusLabel('roadmap')}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.messaging.sidebar', 'Messages')}
enabled={staged.messaging}
onToggle={() => { /* locked */ }}
disabled
lockedReason={NOT_YET_AVAILABLE}
onToggle={(next) => setFlag('messaging', next)}
/>
</Section>
+102
View File
@@ -197,6 +197,7 @@
"navigation": {
"dashboard": "Dashboard",
"events": "Veranstaltungen",
"messages": "Nachrichten",
"settings": "Einstellungen",
"systemHealth": "Systemzustand",
"archives": "Archive",
@@ -3135,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": {
@@ -5533,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."
}
}
+102
View File
@@ -198,6 +198,7 @@
"dashboard": "Dashboard",
"events": "Events",
"archives": "Archives",
"messages": "Messages",
"settings": "Settings",
"systemHealth": "System health",
"eventTypes": "Event Types",
@@ -2697,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": {
@@ -5531,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."
}
}
@@ -21,6 +21,7 @@ import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel';
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard';
import { Palette, RefreshCw, Info } from 'lucide-react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useModal, useMutationWithToast } from '../../hooks';
@@ -802,6 +803,7 @@ export const EmailConfigPage: React.FC = () => {
{/* Email Templates Tab */}
{/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */}
{activeTab === 'smtp' && featureFlags.incomingMail && <IncomingMailConfigCard />}
{activeTab === 'smtp' && featureFlags.messaging && <CustomerMailboxCard />}
{activeTab === 'templates' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -13,7 +13,7 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams, Link } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Eye, Save } from 'lucide-react';
@@ -25,6 +25,7 @@ import {
} from '../../../services/contracts.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { customerAdminService } from '../../../services/customerAdmin.service';
interface BlockRow {
blockId: number;
@@ -39,6 +40,7 @@ interface BlockRow {
export const ContractEditorPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const numericId = id ? parseInt(id, 10) : null;
@@ -67,6 +69,28 @@ export const ContractEditorPage: React.FC = () => {
const [projectId, setProjectId] = useState<number | null>(null);
const [blocks, setBlocks] = useState<BlockRow[]>([]);
// Prefill the customer when opened as "new contract for this customer"
// (?customerAccountId=42), e.g. from the Messages view. New contracts only;
// mirrors QuoteEditorPage / BillEditorPage.
useEffect(() => {
if (isEdit || customerAccountId) return;
const raw = searchParams.get('customerAccountId');
const cid = raw ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(cid) || cid <= 0) return;
let cancelled = false;
(async () => {
try {
const c = await customerAdminService.get(cid);
if (cancelled) return;
setCustomerAccountId(c.id);
setCustomerLabel(c.companyName || c.displayName || [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email);
setCustomerIsPassive(Boolean(c.isPassive));
if (c.preferredLanguage) setLanguage(c.preferredLanguage);
} catch { /* ignore — admin can still pick manually */ }
})();
return () => { cancelled = true; };
}, [isEdit, searchParams, customerAccountId]);
// Load existing contract on edit.
const { data: existing, isLoading: existingLoading } = useQuery({
queryKey: ['contract', numericId],
@@ -0,0 +1,176 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { X, Plus, FileText } from 'lucide-react';
import { Button, Loading } from '../../../components/common';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { quotesService } from '../../../services/quotes.service';
import { contractsService } from '../../../services/contracts.service';
import { billsService } from '../../../services/bills.service';
/**
* From a customer message: resolve (or pick/create) the customer, then either
* create a NEW document of the given type (jumps to the real editor prefilled
* with the customer) or SELECT an existing one to reference in a reply. Reuses
* the CRM editors, list endpoints and CustomerPicker — no duplicated doc logic.
*/
export type DocType = 'quote' | 'contract' | 'invoice' | 'gallery';
const CONFIG: Record<DocType, { label: string; newRoute: string; hasExisting: boolean }> = {
quote: { label: 'Quote', newRoute: '/admin/clients/quotes/new', hasExisting: true },
contract: { label: 'Contract', newRoute: '/admin/clients/contracts/new', hasExisting: true },
invoice: { label: 'Invoice', newRoute: '/admin/clients/bills/new', hasExisting: true },
gallery: { label: 'Gallery', newRoute: '/admin/events/new', hasExisting: false },
};
interface DocRow { id: number; number: string; status: string }
type SelCustomer = { id: number; email: string; label: string };
export const DocumentActionModal: React.FC<{
docType: DocType;
senderEmail: string;
onCompose: (init: { to: string; subject: string; html: string }) => void;
onClose: () => void;
t: (k: string, d?: string) => string;
}> = ({ docType, senderEmail, onCompose, onClose, t }) => {
const navigate = useNavigate();
const cfg = CONFIG[docType];
const [customer, setCustomer] = useState<SelCustomer | null>(null);
const [resolving, setResolving] = useState(true);
const pick = (c: { id: number; email: string; displayName?: string | null; companyName?: string | null }) =>
setCustomer({ id: c.id, email: c.email, label: c.companyName || c.displayName || c.email });
// Resolve the customer from the message's sender address (first match).
useEffect(() => {
let cancelled = false;
setResolving(true);
customerAdminService.search(senderEmail)
.then((rows) => {
if (cancelled) return;
// search matches email/name/company PREFIXES — only auto-pick on an
// EXACT email match so a spoofed/partial sender can't prefill the wrong
// customer. Otherwise leave the picker for the admin to choose.
const target = senderEmail.trim().toLowerCase();
const exact = rows.find((r) => (r.email || '').toLowerCase() === target);
if (exact) pick(exact);
})
.catch(() => {})
.finally(() => { if (!cancelled) setResolving(false); });
return () => { cancelled = true; };
}, [senderEmail]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [onClose]);
const existing = useQuery({
queryKey: ['messages', 'docs', docType, customer?.id],
enabled: !!customer && cfg.hasExisting,
queryFn: async (): Promise<DocRow[]> => {
const customerAccountId = customer!.id;
if (docType === 'quote') {
const r = await quotesService.list({ customerAccountId, page: 1, pageSize: 20 });
return r.quotes.map((q) => ({ id: q.id, number: q.quoteNumber, status: q.status }));
}
if (docType === 'contract') {
const r = await contractsService.list({ customerAccountId, page: 1, pageSize: 20 });
return r.contracts.map((c) => ({ id: c.id, number: c.contractNumber, status: c.status }));
}
const r = await billsService.list({ customerAccountId, page: 1, pageSize: 20 });
return r.invoices.map((i) => ({ id: i.id, number: i.invoiceNumber, status: i.status }));
},
});
const createNew = () => {
if (!customer && docType !== 'gallery') return;
navigate(docType === 'gallery' || !customer ? cfg.newRoute : `${cfg.newRoute}?customerAccountId=${customer.id}`);
onClose();
};
const pickExisting = (d: DocRow) => {
const html = `<p><br></p><p>${cfg.label} <strong>${d.number}</strong></p><p><br></p>`;
onCompose({ to: customer?.email || senderEmail, subject: `${cfg.label} ${d.number}`, html });
onClose();
};
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(560px,96vw)] max-h-[88vh] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t(`messages.doc.${docType}`, cfg.label)}
</span>
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
<div>
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('messages.customer', 'Customer')}</div>
<CustomerPicker
value={customer?.id ?? null}
label={customer?.label || ''}
onSelect={pick}
onCreate={pick}
onClear={() => setCustomer(null)}
/>
{resolving && <p className="mt-1 text-xs text-neutral-400">{t('messages.resolvingCustomer', 'Matching the sender to a customer…')}</p>}
{!resolving && !customer && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('messages.noCustomerMatch', 'No customer matched this sender — search for one or create a new customer above.')}
</p>
)}
</div>
{customer && (
<>
<Button variant="primary" onClick={createNew} leftIcon={<Plus className="w-4 h-4" />} className="w-full justify-center">
{t('messages.createNewDoc', 'Create new {{label}}', { label: t(`messages.doc.${docType}`, cfg.label) } as any)}
</Button>
{cfg.hasExisting && (
<div>
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
{t('messages.existingDocs', 'Or reference an existing one')}
</div>
{existing.isLoading ? (
<Loading />
) : (existing.data && existing.data.length > 0) ? (
<div className="flex flex-col gap-1.5 max-h-[38vh] overflow-y-auto">
{existing.data.map((d) => (
<button
key={d.id}
onClick={() => pickExisting(d)}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 text-left"
>
<FileText className="w-4 h-4 text-neutral-400 flex-none" />
<span className="font-mono text-[13px] text-neutral-800 dark:text-neutral-100">{d.number}</span>
<span className="ml-auto text-[11px] text-neutral-400">{d.status}</span>
</button>
))}
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.noExistingDocs', 'No existing documents for this customer yet.')}</p>
)}
</div>
)}
{!cfg.hasExisting && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('messages.galleryCreateOnly', 'Galleries are event-based — this opens the event editor, where you can assign the customer.')}
</p>
)}
</>
)}
</div>
</div>
</div>
);
};
export default DocumentActionModal;
@@ -0,0 +1,119 @@
import React, { useEffect, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import DOMPurify from 'dompurify';
import { X, Send as SendIcon } from 'lucide-react';
import { toast } from 'react-toastify';
import { emailService } from '../../../services/email.service';
import { Button } from '../../../components/common';
/**
* Compose / reply modal. The body is pre-loaded with the rendered template (or a
* reply stub) and is FULLY EDITABLE — the admin can rewrite it or drop a note
* anywhere before sending. On send it goes out as-is (server-sanitized), no
* template re-render, and is recorded as a manual send (Customers ▸ Sent).
*/
export interface ComposerInit {
to: string;
cc?: string;
subject: string;
html: string;
replyToReceivedId?: number;
}
const inputCls = 'flex-1 px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent';
export const MessageComposer: React.FC<{
init: ComposerInit;
title?: string;
accountKey?: string;
onClose: () => void;
onSent: () => void;
t: (k: string, d?: string) => string;
}> = ({ init, title, accountKey, onClose, onSent, t }) => {
const [to, setTo] = useState(init.to);
const [cc, setCc] = useState(init.cc || '');
const [subject, setSubject] = useState(init.subject);
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Sanitize before it hits the contentEditable innerHTML — the initial body
// can include untrusted text (e.g. an inbound sender name in a reply stub).
if (bodyRef.current) bodyRef.current.innerHTML = DOMPurify.sanitize(init.html || '');
// Load initial body exactly once; further edits are the admin's.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [onClose]);
const send = useMutation({
mutationFn: () => emailService.sendMessage({
to: to.trim(),
cc: cc.trim() || undefined,
subject: subject.trim(),
html: bodyRef.current?.innerHTML || '',
replyToReceivedId: init.replyToReceivedId,
accountKey,
}),
onSuccess: () => { toast.success(t('messages.sentToast', 'Message sent.')); onSent(); onClose(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.sendFailed', 'Failed to send message.')),
});
const canSend = !!to.trim() && !!subject.trim() && !send.isPending;
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(920px,97vw)] h-[min(780px,92vh)] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">{title || t('messages.compose', 'Compose message')}</span>
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 flex flex-col gap-3 overflow-y-auto flex-1 min-h-0">
<label className="flex items-center gap-2 text-sm">
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.to', 'To')}</span>
<input className={inputCls} value={to} onChange={(e) => setTo(e.target.value)} placeholder="[email protected]" />
</label>
<label className="flex items-center gap-2 text-sm">
<span className="w-16 text-neutral-500 dark:text-neutral-400">Cc</span>
<input className={inputCls} value={cc} onChange={(e) => setCc(e.target.value)} placeholder={t('messages.optional', 'optional')} />
</label>
<label className="flex items-center gap-2 text-sm">
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.subject', 'Subject')}</span>
<input className={inputCls} value={subject} onChange={(e) => setSubject(e.target.value)} />
</label>
<div className="flex-1 min-h-0 flex flex-col">
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')}
</div>
<div
ref={bodyRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
className="min-h-[240px] flex-1 overflow-y-auto rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 p-3 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
<div className="flex items-center gap-2 px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<span className="text-xs text-neutral-400">{t('messages.sendsFromHint', 'Sends from your configured outgoing address.')}</span>
<div className="ml-auto flex gap-2">
<Button variant="outline" onClick={onClose}>{t('messages.cancel', 'Cancel')}</Button>
<Button variant="primary" onClick={() => send.mutate()} isLoading={send.isPending} disabled={!canSend} leftIcon={<SendIcon className="w-4 h-4" />}>
{t('messages.send', 'Send')}
</Button>
</div>
</div>
</div>
</div>
);
};
export default MessageComposer;
@@ -0,0 +1,822 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import {
Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip,
FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText,
Link2, X, ChevronLeft, ChevronRight, Mail, RefreshCw, PenSquare, Search, RotateCcw, type LucideIcon,
} from 'lucide-react';
import { emailService, type ReceivedEmail, type MailIdentities } from '../../../services/email.service';
import { accountingService } from '../../../services/accounting.service';
import { Loading } from '../../../components/common';
import { MessageComposer, type ComposerInit } from './MessageComposer';
import { DocumentActionModal, type DocType } from './DocumentActionModal';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
/**
* Admin "Messages" — 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' | 'state';
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; state?: 'archived' | 'deleted'; 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' }) : '';
// Compact mailbox label — just the local part + '@' (the domain clutters the
// narrow sidebar); full address stays in the hover title.
const localPart = (addr?: string | null) => (addr ? `${addr.split('@')[0]}@` : '');
// Escape untrusted text before it goes into an HTML string. The inbound From
// header carries an attacker-controlled display name; the reply stub builds raw
// HTML for the (contentEditable) composer, so this MUST be escaped there.
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) => (({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' } as Record<string, string>)[c]));
// A From/To header can be "Display Name <addr@x>" — 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<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',
received: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-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 [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null);
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({
mutationFn: () => emailService.pollIncoming(),
onSuccess: (r) => {
if (r.skipped === 'disabled') toast.info(t('messages.syncDisabled', 'Incoming mail is off — enable it under Settings → Features.'));
else if (r.skipped === 'unconfigured') toast.info(t('messages.syncUnconfigured', 'Configure a mailbox under Settings → Email first.'));
else if (r.skipped === 'busy') toast.info(t('messages.syncBusy', 'A sync is already running.'));
else toast.success(t('messages.syncOk', 'Checked mailboxes — {{count}} new.', { count: r.processed || 0 }));
acctQuery.refetch(); custQuery.refetch(); queueQuery.refetch();
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.syncFailed', 'Sync failed.')),
});
const openNewMessage = () => setComposer({
init: { to: '', subject: '', html: '' },
title: t('messages.newMessage', 'New message'),
accountKey: 'customers',
});
const queueQuery = useQuery({
queryKey: ['messages', 'queue', sq],
queryFn: () => emailService.listQueue({ pageSize: 100, q: sq }),
refetchInterval: 60000,
});
const acctQuery = useQuery({
queryKey: ['messages', 'received', 'accounting', sq],
queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100, q: sq }),
refetchInterval: 60000,
});
const custQuery = useQuery({
queryKey: ['messages', 'received', 'customers', sq],
queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100, q: sq }),
refetchInterval: 60000,
});
const identitiesQuery = useQuery({
queryKey: ['messages', 'identities'],
queryFn: () => emailService.getIdentities(),
});
const identities = identitiesQuery.data;
// Archived / Deleted system folders — fetch queue + received for that state,
// on demand (only when the folder is open).
const folderState: 'archived' | 'deleted' | undefined =
activeFolder === 'archived' ? 'archived' : activeFolder === 'deleted' ? 'deleted' : undefined;
const stateQueueQuery = useQuery({
queryKey: ['messages', 'state-queue', folderState, sq],
enabled: !!folderState,
queryFn: () => emailService.listQueue({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }),
});
const stateRecvQuery = useQuery({
queryKey: ['messages', 'state-received', folderState, sq],
enabled: !!folderState,
queryFn: () => emailService.listReceived({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }),
});
const refetchAll = () => {
queueQuery.refetch(); acctQuery.refetch(); custQuery.refetch();
stateQueueQuery.refetch(); stateRecvQuery.refetch();
};
const stateMut = useMutation({
mutationFn: (v: { kind: 'queue' | 'received'; id: number; state: 'active' | 'archived' | 'deleted' }) =>
emailService.setItemState(v.kind, v.id, v.state),
onSuccess: () => { setSelection(null); refetchAll(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
});
const purgeMut = useMutation({
mutationFn: (v: { kind: 'queue' | 'received'; id: number }) => emailService.deleteItem(v.kind, v.id),
onSuccess: () => { setSelection(null); refetchAll(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
});
// Archive / Delete (soft) / Restore, acting on the current selection. Delete
// from the Deleted folder is permanent.
const doItemAction = (action: 'archive' | 'delete' | 'restore') => {
if (!selection) return;
const kind = selection.kind;
const id = selection.kind === 'queue' ? selection.id : selection.item.id;
if (action === 'restore') stateMut.mutate({ kind, id, state: 'active' });
else if (action === 'archive') stateMut.mutate({ kind, id, state: 'archived' });
else if (folderState === 'deleted') purgeMut.mutate({ kind, id });
else stateMut.mutate({ kind, id, state: 'deleted' });
};
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: identities?.customers || undefined, 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: identities?.accounting || undefined, 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: identities?.automated || undefined, color: '#7a52d6', folders: [
{ id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'system' },
] },
], [t, identities]);
// Cross-account system folders — Archived + Deleted (trash).
const systemFolders: Folder[] = useMemo(() => [
{ id: 'archived', name: t('messages.folder.archived', 'Archived'), icon: Archive, src: 'state', state: 'archived' },
{ id: 'deleted', name: t('messages.folder.deleted', 'Deleted'), icon: Trash2, src: 'state', state: 'deleted' },
], [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 };
const sf = systemFolders.find((f) => f.id === activeFolder);
if (sf) return { a: { id: 'system', name: sf.name, color: '#94a3b8', folders: [] } as Account, f: sf };
return { a: accounts[0], f: accounts[0].folders[0] };
}, [accounts, systemFolders, 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 (
<div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]">
<div className="flex items-center gap-3 mb-3">
<div className="flex-none">
<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 className="relative flex-1 max-w-md ml-auto">
<Search className="w-4 h-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('messages.searchPlaceholder', 'Search this folder…')}
className="w-full h-9 pl-9 pr-3 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex items-center gap-2 flex-none">
<button
onClick={() => sync.mutate()}
disabled={sync.isPending}
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-neutral-300 dark:border-neutral-700 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-60"
>
<RefreshCw className={`w-4 h-4 ${sync.isPending ? 'animate-spin' : ''}`} />
{t('messages.sync', 'Sync')}
</button>
<button
onClick={openNewMessage}
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-accent-dark text-white text-sm font-medium hover:opacity-90"
>
<PenSquare className="w-4 h-4" />
{t('messages.newMessage', 'New message')}
</button>
</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 title={a.addr} className="ml-auto text-[11px] font-medium font-mono text-neutral-400 dark:text-neutral-500 truncate max-w-[7rem]">{localPart(a.addr)}</span>}
</div>
<div className="flex flex-col gap-0.5">
{a.folders.map((f) => {
const c = countFor(f);
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-accent-soft text-on-accent-soft font-semibold'
: '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-on-accent-soft' : 'text-neutral-400'}`}>{c}</span>
)}
</button>
);
})}
</div>
</div>
))}
{/* System folders — Archived + Deleted, across all accounts. */}
<div className="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-800 flex flex-col gap-0.5">
{systemFolders.map((f) => {
const active = f.id === activeFolder;
return (
<button
key={f.id}
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
active
? 'bg-accent-soft text-on-accent-soft font-semibold'
: '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>
</button>
);
})}
</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.f.src === 'state'
? t('messages.acrossAccounts', 'Across all accounts')
: 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={folder.f.src === 'state' ? stateQueueQuery.data?.items : queueFor(folder.f.origin)}
received={folder.f.src === 'state' ? stateRecvQuery.data?.items : receivedItems}
loading={folder.f.src === 'state'
? (stateQueueQuery.isLoading || stateRecvQuery.isLoading)
: folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
search={search}
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}
identities={identities}
flags={flags}
folderState={folderState}
onViewDoc={setPdfDocId}
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })}
onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })}
onItemAction={doItemAction}
t={t}
/>
</section>
</div>
{pdfDocId != null && <PdfModal docId={pdfDocId} onClose={() => setPdfDocId(null)} t={t} />}
{composer && (
<MessageComposer
init={composer.init}
title={composer.title}
accountKey={composer.accountKey}
onClose={() => setComposer(null)}
onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
t={t}
/>
)}
{docAction && (
<DocumentActionModal
docType={docAction.docType}
senderEmail={docAction.senderEmail}
onCompose={(init) => { setDocAction(null); setComposer({ init: { to: init.to, subject: init.subject, html: init.html }, title: init.subject, accountKey: 'customers' }); }}
onClose={() => setDocAction(null)}
t={t}
/>
)}
</div>
);
};
// ─────────────────────────────────────────────────────────── message list ──
const MessageList: React.FC<{
folder: Folder;
queue?: import('../../../services/email.service').EmailQueueItem[];
received?: ReceivedEmail[];
loading: boolean;
search: string;
selection: Selection;
onSelect: (s: Selection) => void;
t: (k: string, d?: string) => string;
}> = ({ folder, queue, received, loading, search, 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 qRows = (queue || []).map((m) => ({
key: `q${m.id}`,
sortKey: m.sentAt || m.createdAt || '',
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,
}));
const rRows = (received || []).map((m) => ({
key: `r${m.id}`,
sortKey: m.received_at || '',
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,
}));
// Archived/Deleted folders (src 'state') merge both streams by date.
let rows = folder.src === 'queue' ? qRows
: folder.src === 'received' ? rRows
: [...qRows, ...rRows].sort((a, b) => (b.sortKey || '').localeCompare(a.sortKey || ''));
const q = search.trim().toLowerCase();
if (q) rows = rows.filter((r) => r.who.toLowerCase().includes(q) || r.subject.toLowerCase().includes(q));
if (rows.length === 0) {
return <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">{q ? t('messages.noSearchResults', 'No matches') : 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-accent-dark bg-accent-soft'
: '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;
identities?: MailIdentities | null;
flags: Record<string, boolean>;
folderState?: 'archived' | 'deleted';
onViewDoc: (id: number) => void;
onOpenAccounting: () => void;
onCompose: (init: ComposerInit, title?: string) => void;
onOpenDoc: (docType: DocType, senderEmail: string) => void;
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
t: (k: string, d?: string) => string;
}> = ({ selection, account, identities, flags, folderState, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, onItemAction, 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>
);
}
// 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 = 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
? () => {
const it = selection.item;
const subj = /^re:/i.test(it.subject || '') ? (it.subject || '') : `Re: ${it.subject || ''}`;
const quoted = `<p><br></p><p style="color:#888;font-size:12px">${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${escapeHtml(it.from_address || '')}:</p>`;
onCompose({ to: extractEmail(it.from_address), subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply'));
}
: undefined;
// Quote/Contract/Invoice/Gallery open the document-action flow (resolve the
// customer, then create-new or select-existing). Customer-facing streams only.
const onDoc = !isAcct && recipient
? (docType: DocType) => onOpenDoc(docType, recipient)
: undefined;
return (
<div className="flex flex-col min-h-0 flex-1">
<Toolbar isAcct={isAcct} flags={flags} folderState={folderState} onReply={onReply} onDoc={onDoc} onItemAction={onItemAction} t={t} />
<div className="flex-1 overflow-y-auto p-6">
{selection.kind === 'queue' ? (
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
<QueueDetail d={detailQuery.data} fromAddr={identities?.automated} t={t} />
) : (
<div className="text-sm text-neutral-500">{t('messages.loadError', 'Could not load this message.')}</div>
)
) : (
<ReceivedDetail
item={selection.item}
mailboxAddr={selection.item.account_key === 'customers' ? identities?.customers : identities?.accounting}
onViewDoc={onViewDoc}
onOpenAccounting={onOpenAccounting}
t={t}
/>
)}
</div>
</div>
);
};
const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; fromAddr?: string | null; t: (k: string, d?: string) => string }> = ({ d, fromAddr, 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">{fromAddr || '—'}</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' }}>
{/* Our own template output, but rendered with a strict script-less,
no-same-origin sandbox anyway — matches the inbound-mail pane. */}
<iframe title="Email body" sandbox="" 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;
mailboxAddr?: string | null;
onViewDoc: (id: number) => void;
onOpenAccounting: () => void;
t: (k: string, d?: string) => string;
}> = ({ item, mailboxAddr, 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 || mailboxAddr || '—';
return (
<>
<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">{toAddr}</span>
</div>
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(item.received_at)}</div>
</div>
{detail.isLoading ? (
<div className="mt-4"><Loading /></div>
) : detail.data?.body_html ? (
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '48vh' }}>
{/* Sanitized server-side; rendered with a strict (script-less, no
same-origin) sandbox as a second layer against untrusted mail. */}
<iframe title="Email body" sandbox="" srcDoc={detail.data.body_html} className="w-full h-full border-0" />
</div>
) : detail.data?.body_text ? (
<pre className="mt-4 whitespace-pre-wrap text-sm text-neutral-700 dark:text-neutral-300 font-sans">{detail.data.body_text}</pre>
) : (
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('messages.noInboundBody', 'No message body was captured for this email.')}
</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-accent-dark hover:opacity-90 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;
flags: Record<string, boolean>;
folderState?: 'archived' | 'deleted';
onReply?: () => void;
onDoc?: (docType: DocType) => void;
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
t: (k: string, d?: string) => string;
}> = ({ isAcct, flags, folderState, onReply, onDoc, onItemAction, t }) => {
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
const enabled = !!onClick;
return (
<button
onClick={onClick}
disabled={!enabled}
title={enabled ? undefined : 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 ${
enabled ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 ' : 'cursor-not-allowed opacity-50 '
}${accent ? 'text-accent-dark font-semibold' : 'text-neutral-600 dark:text-neutral-300'}`}
>
<Icon className="w-[15px] h-[15px]" />{label}
</button>
);
};
const doc = (docType: DocType) => (onDoc ? () => onDoc(docType) : undefined);
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">
<Tb icon={Reply} label={t('messages.reply', 'Reply')} onClick={onReply} />
<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 />
</>
) : (
<>
{flags.quotes && <Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={doc('quote')} />}
{flags.contracts && <Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent onClick={doc('contract')} />}
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={doc('gallery')} />
{flags.bills && <Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={doc('invoice')} />}
</>
)}
<span className="flex-1" />
{folderState && <Tb icon={RotateCcw} label={t('messages.restore', 'Restore')} onClick={() => onItemAction('restore')} />}
{folderState !== 'archived' && <Tb icon={Archive} label={t('messages.archive', 'Archive')} onClick={() => onItemAction('archive')} />}
<Tb
icon={Trash2}
label={folderState === 'deleted' ? t('messages.deleteForever', 'Delete permanently') : t('messages.delete', 'Delete')}
onClick={() => onItemAction('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;
+92 -1
View File
@@ -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,46 @@ 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;
// Outgoing (SMTP) identity — replies from this mailbox send from here.
smtp_host?: string | null;
smtp_port?: number;
smtp_secure?: boolean;
smtp_user?: string | null;
smtp_pass?: string;
from_email?: string | null;
from_name?: string | null;
enabled?: boolean;
}
/** Resolved sender/mailbox addresses for the Messages sidebar. */
export interface MailIdentities {
automated: string | null;
accounting: string | null;
customers: string | null;
}
export const emailService = {
// Get email configuration
async getConfig(): Promise<EmailConfig> {
@@ -172,10 +220,34 @@ export const emailService = {
const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {});
return response.data;
},
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted'; q?: string } = {}): Promise<ReceivedEmailsResponse> {
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
return response.data;
},
/** Archive / Delete (soft) / Restore an email. kind = 'queue' | 'received'. */
async setItemState(kind: 'queue' | 'received', id: number, state: 'active' | 'archived' | 'deleted'): Promise<void> {
await api.post(`/admin/email/item/${kind}/${id}/state`, { state });
},
/** Permanently delete an email (only from the Deleted folder). */
async deleteItem(kind: 'queue' | 'received', id: number): Promise<void> {
await api.delete(`/admin/email/item/${kind}/${id}`);
},
async getReceivedItem(id: number): Promise<ReceivedEmailDetail> {
const response = await api.get<ReceivedEmailDetail>(`/admin/email/received/${id}`);
return response.data;
},
// Additional inbound mailboxes (e.g. the customer hello@ box).
async listMailAccounts(): Promise<MailAccount[]> {
const response = await api.get<{ items: MailAccount[] }>('/admin/email/accounts');
return response.data.items;
},
async saveMailAccount(account: MailAccount): Promise<void> {
await api.post('/admin/email/accounts', account);
},
async testMailAccount(account: Partial<MailAccount>): Promise<ImapTestResult> {
const response = await api.post<ImapTestResult>('/admin/email/accounts/test', account);
return response.data;
},
// Test email configuration
async testEmail(testEmail: string): Promise<void> {
@@ -197,6 +269,8 @@ export const emailService = {
async listQueue(params: {
status?: EmailQueueStatus;
emailType?: string;
origin?: 'system' | 'manual';
state?: 'active' | 'archived' | 'deleted';
q?: string;
from?: string;
to?: string;
@@ -207,6 +281,23 @@ export const emailService = {
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;
},
/** Send a human-composed (edited) email — reply or document message. */
async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number; accountKey?: string }): Promise<void> {
await api.post('/admin/email/send', payload);
},
/** Resolved sender/mailbox addresses for the Messages sidebar. */
async getIdentities(): Promise<MailIdentities> {
const response = await api.get<MailIdentities>('/admin/email/identities');
return response.data;
},
// Get all email templates
async getTemplates(): Promise<EmailTemplate[]> {
const response = await api.get<EmailTemplate[]>('/admin/email/templates');