feat(messages): Phase 3 — editable-template composer, reply + create actions

The CRM action buttons and Reply now open a send-composer, not a silent
templated send.

- New send-composer (MessageComposer): loads the rendered template (via
  previewTemplate) or a reply stub into a fully-editable body — 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.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
  configured SMTP identity; POST /admin/email/send sanitizes + sends + records
  the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
  stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
  the human/edited messages (which finally populates that folder). /queue gains
  an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
  Create Quote/Contract/Invoice open the composer with that template loaded;
  Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
  (later phases). After send, jumps to Customers ▸ Sent.

Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
This commit is contained in:
Luca
2026-07-07 10:42:54 +02:00
parent ee46cf2125
commit 768e84711f
6 changed files with 324 additions and 28 deletions
@@ -0,0 +1,114 @@
import React, { useEffect, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
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-blue-500';
export const MessageComposer: React.FC<{
init: ComposerInit;
title?: string;
onClose: () => void;
onSent: () => void;
t: (k: string, d?: string) => string;
}> = ({ init, title, 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(() => {
if (bodyRef.current) bodyRef.current.innerHTML = 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,
}),
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(720px,96vw)] max-h-[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">
<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>
<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-[220px] max-h-[46vh] 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-blue-500"
/>
</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;
@@ -10,9 +10,10 @@ import {
import { emailService, type ReceivedEmail } from '../../../services/email.service';
import { accountingService } from '../../../services/accounting.service';
import { Loading } from '../../../components/common';
import { MessageComposer, type ComposerInit } from './MessageComposer';
/**
* Admin "Messages" — Phase 1 read-only viewer over the mail picpeak already
* 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
@@ -20,7 +21,7 @@ import { Loading } from '../../../components/common';
*/
type FolderSrc = 'queue' | 'received' | 'empty';
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; note?: string; }
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; note?: string; }
interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; }
type Selection =
@@ -57,11 +58,12 @@ const STATUS_STYLES: Record<string, string> = {
};
export const MessagesPage: React.FC = () => {
const { t } = useTranslation();
const { t, i18n } = 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 } | null>(null);
const queueQuery = useQuery({
queryKey: ['messages', 'queue'],
@@ -90,24 +92,31 @@ export const MessagesPage: React.FC = () => {
] },
{ id: 'cust', name: t('messages.account.customers', 'Customers'), addr: 'hello@', color: '#2563c9', folders: [
{ id: 'cust-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'customers' },
{ id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'empty',
note: t('messages.empty.customerSent', 'Your hand-written replies will appear here once compose ships (next phase).') },
{ id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'manual' },
] },
{ id: 'acct', name: t('messages.account.accounting', 'Accounting'), addr: 'rechnungen@', color: '#12876a', folders: [
{ id: 'acct-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'accounting' },
] },
{ id: 'auto', name: t('messages.account.automated', 'Automated'), addr: 'no-reply@', color: '#7a52d6', folders: [
{ id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue' },
{ id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'system' },
] },
], [t]);
// Sent stream is split client-side by origin: system (Automated) vs manual
// (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system.
const queueItemsAll = queueQuery.data?.items || [];
const queueFor = (origin?: 'system' | 'manual') =>
origin === 'manual' ? queueItemsAll.filter((i) => i.origin === 'manual')
: origin === 'system' ? queueItemsAll.filter((i) => i.origin !== 'manual')
: queueItemsAll;
const folder = useMemo(() => {
for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f };
return { a: accounts[0], f: accounts[0].folders[0] };
}, [accounts, activeFolder]);
const countFor = (f: Folder): number | undefined => {
if (f.src === 'queue') return queueTotal;
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;
@@ -194,7 +203,7 @@ export const MessagesPage: React.FC = () => {
<div className="flex-1 overflow-y-auto">
<MessageList
folder={folder.f}
queue={queueQuery.data?.items}
queue={queueFor(folder.f.origin)}
received={receivedItems}
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
selection={selection}
@@ -209,14 +218,25 @@ export const MessagesPage: React.FC = () => {
<ReadingPane
selection={selection}
account={folder.a}
lang={i18n.language}
onViewDoc={setPdfDocId}
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
onCompose={(init, title) => setComposer({ init, title })}
t={t}
/>
</section>
</div>
{pdfDocId != null && <PdfModal docId={pdfDocId} onClose={() => setPdfDocId(null)} t={t} />}
{composer && (
<MessageComposer
init={composer.init}
title={composer.title}
onClose={() => setComposer(null)}
onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
t={t}
/>
)}
</div>
);
};
@@ -306,10 +326,12 @@ const MessageList: React.FC<{
const ReadingPane: React.FC<{
selection: Selection;
account: Account;
lang: string;
onViewDoc: (id: number) => void;
onOpenAccounting: () => void;
onCompose: (init: ComposerInit, title?: string) => void;
t: (k: string, d?: string) => string;
}> = ({ selection, account, onViewDoc, onOpenAccounting, t }) => {
}> = ({ selection, account, lang, onViewDoc, onOpenAccounting, onCompose, t }) => {
const detailQuery = useQuery({
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
@@ -332,9 +354,38 @@ const ReadingPane: React.FC<{
const isAcct = selection.kind === 'received'
? selection.item.account_key !== 'customers'
: account.id === 'acct';
const recipient = selection.kind === 'received'
? (selection.item.from_address || '')
: (detailQuery.data?.recipientEmail || '');
// Reply only makes sense for an inbound message with a sender.
const onReply = selection.kind === 'received' && selection.item.from_address
? () => {
const it = selection.item;
const subj = /^re:/i.test(it.subject || '') ? (it.subject || '') : `Re: ${it.subject || ''}`;
const quoted = `<p><br></p><p style="color:#888;font-size:12px">${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${it.from_address}:</p>`;
onCompose({ to: it.from_address || '', subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply'));
}
: undefined;
// Create-from-template opens the composer with the rendered template loaded
// (freely editable); empty key = blank compose (gallery has no send template).
const onCreate = !isAcct
? async (key: string, label: string) => {
if (!key) { onCompose({ to: recipient, subject: '', html: '' }, label); return; }
try {
const p = await emailService.previewTemplate(key, { customer_name: recipient.split('@')[0] || '' }, lang || 'en');
onCompose({ to: recipient, subject: p.subject, html: p.body_html }, label);
} catch {
onCompose({ to: recipient, subject: label, html: '' }, label);
}
}
: undefined;
return (
<div className="flex flex-col min-h-0 flex-1">
<Toolbar isAcct={isAcct} readonly={selection.kind === 'queue'} t={t} />
<Toolbar isAcct={isAcct} onReply={onReply} onCreate={onCreate} t={t} />
<div className="flex-1 overflow-y-auto p-6">
{selection.kind === 'queue' ? (
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
@@ -461,22 +512,33 @@ const ReceivedDetail: React.FC<{
};
// ─────────────────────────────────────────────────────────────── 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>
);
const Toolbar: React.FC<{
isAcct: boolean;
onReply?: () => void;
onCreate?: (key: string, label: string) => void;
t: (k: string, d?: string) => string;
}> = ({ isAcct, onReply, onCreate, t }) => {
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
const enabled = !!onClick;
return (
<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-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>
);
};
const create = (key: string, labelKey: string, fallback: string) =>
onCreate ? () => onCreate(key, t(labelKey, fallback)) : 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">
{!readonly && <Tb icon={Reply} label={t('messages.reply', 'Reply')} />}
{!readonly && <Tb icon={ReplyAll} label={t('messages.replyAll', 'Reply all')} />}
<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 ? (
@@ -486,10 +548,10 @@ const Toolbar: React.FC<{ isAcct: boolean; readonly: boolean; t: (k: string, d?:
</>
) : (
<>
<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 />
<Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={create('quote_sent', 'messages.createQuote', 'Quote')} />
<Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent onClick={create('contract_sent', 'messages.createContract', 'Contract')} />
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={create('', 'messages.createGallery', 'Gallery')} />
<Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={create('invoice_sent', 'messages.createInvoice', 'Invoice')} />
</>
)}
<span className="flex-1" />
+8
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 {
@@ -244,6 +246,7 @@ export const emailService = {
async listQueue(params: {
status?: EmailQueueStatus;
emailType?: string;
origin?: 'system' | 'manual';
q?: string;
from?: string;
to?: string;
@@ -260,6 +263,11 @@ export const emailService = {
return response.data;
},
/** Send a human-composed (edited) email — reply or document message. */
async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number }): Promise<void> {
await api.post('/admin/email/send', payload);
},
// Get all email templates
async getTemplates(): Promise<EmailTemplate[]> {
const response = await api.get<EmailTemplate[]>('/admin/email/templates');