From 768e84711f3362069d7ce6867e63c727d2009a46 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:42:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(messages):=20Phase=203=20=E2=80=94=20edita?= =?UTF-8?q?ble-template=20composer,=20reply=20+=20create=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../migrations/core/155_email_queue_origin.js | 25 ++++ backend/src/routes/adminEmail.js | 57 +++++++++ backend/src/services/emailProcessor.js | 30 +++++ .../pages/admin/messages/MessageComposer.tsx | 114 +++++++++++++++++ .../src/pages/admin/messages/MessagesPage.tsx | 118 +++++++++++++----- frontend/src/services/email.service.ts | 8 ++ 6 files changed, 324 insertions(+), 28 deletions(-) create mode 100644 backend/migrations/core/155_email_queue_origin.js create mode 100644 frontend/src/pages/admin/messages/MessageComposer.tsx diff --git a/backend/migrations/core/155_email_queue_origin.js b/backend/migrations/core/155_email_queue_origin.js new file mode 100644 index 00000000..106bc4ef --- /dev/null +++ b/backend/migrations/core/155_email_queue_origin.js @@ -0,0 +1,25 @@ +/** + * Messages Phase 3 — distinguish human-composed sends from system mail. + * + * `origin` is 'system' for everything the app queues automatically (invoices, + * reminders, gallery notices — the Automated stream) and 'manual' for emails an + * admin composed/edited in the Messages composer (replies + document messages — + * the Customers ▸ Sent stream). Existing rows default to 'system'. + */ +exports.up = async function up(knex) { + const has = await knex.schema.hasColumn('email_queue', 'origin'); + if (!has) { + await knex.schema.alterTable('email_queue', (t) => { + t.string('origin', 16).defaultTo('system'); + }); + } +}; + +exports.down = async function down(knex) { + const has = await knex.schema.hasColumn('email_queue', 'origin'); + if (has) { + await knex.schema.alterTable('email_queue', (t) => { + t.dropColumn('origin'); + }); + } +}; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 69942a43..b424379e 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -529,6 +529,7 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r router.get('/queue', adminAuth, requirePermission('email.view'), [ query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']), query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }), + query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']), query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), query('from').optional({ values: 'falsy' }).isISO8601(), query('to').optional({ values: 'falsy' }).isISO8601(), @@ -547,6 +548,9 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ const applyFilters = (qb) => { if (req.query.status) qb.where('email_queue.status', req.query.status); if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType); + // 'system' includes legacy rows (origin was NULL before migration 155). + if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual'); + else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin')); if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from)); if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to)); if (req.query.q) { @@ -575,6 +579,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ 'email_queue.sent_at', 'email_queue.error_message', 'email_queue.retry_count', + 'email_queue.origin', 'email_queue.event_id', 'events.event_name as event_name', 'events.slug as event_slug' @@ -594,6 +599,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ sentAt: r.sent_at, errorMessage: r.error_message, retryCount: r.retry_count, + origin: r.origin || 'system', eventId: r.event_id, eventName: r.event_name || null, eventSlug: r.event_slug || null, @@ -660,6 +666,57 @@ router.get('/queue/:id', adminAuth, requirePermission('email.view'), async (req, } }); +// Send a human-composed email from the Messages composer. The admin already +// edited the body (reply or document message), so it is sent as-is — no +// template render — after a sanitize pass. Recorded in email_queue as a +// 'manual' send so it surfaces under Customers > Sent. +router.post('/send', adminAuth, requirePermission('email.send'), async (req, res) => { + try { + const b = req.body || {}; + const to = String(b.to || '').trim(); + const subject = String(b.subject || '').trim(); + if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { + return res.status(400).json({ error: 'A valid recipient email is required.' }); + } + if (!subject) return res.status(400).json({ error: 'A subject is required.' }); + + const sanitizeHtml = require('sanitize-html'); + const html = sanitizeHtml(String(b.html || ''), { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'style']), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ['src', 'alt', 'width', 'height'], + '*': ['style', 'class'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'cid', 'data'], + }); + const cc = b.cc ? String(b.cc).trim() : null; + + const emailProcessor = require('../services/emailProcessor'); + const result = await emailProcessor.sendRawEmail({ to, cc, subject, html }); + + await db('email_queue').insert({ + recipient_email: to, + email_type: 'manual_message', + email_data: JSON.stringify({ + subject, + cc: cc || undefined, + replyToReceivedId: b.replyToReceivedId || undefined, + messageId: result.messageId, + }), + status: 'sent', + origin: 'manual', + rendered_html: html, + created_at: new Date(), + sent_at: new Date(), + }); + res.json({ ok: true }); + } catch (error) { + logger.error('Manual send error:', error); + res.status(500).json({ error: 'Failed to send message', details: error.message }); + } +}); + // Helper: parse variables JSON safely function parseVariables(template) { try { diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 65be426d..8fd36ac0 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -772,6 +772,35 @@ async function sendTemplateEmail(to, templateKey, variables) { } } +/** + * Send a fully-composed email (subject + HTML the admin already edited in the + * Messages composer) WITHOUT a template. Used for replies + human-sent document + * messages. Uses the configured SMTP identity + from address. Returns + * { messageId, html } so the caller can persist rendered_html for the record. + */ +async function sendRawEmail({ to, cc, subject, html, text, attachments } = {}) { + transporter = await initializeTransporter(); + if (!transporter) throw new Error('Email service not configured'); + const config = await db('email_configs').first(); + if (!config || !config.from_email) throw new Error('Email service not configured'); + const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined); + const atts = Array.isArray(attachments) + ? attachments.filter((a) => a && (a.contentPath || a.path || a.content)) + .map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType })) + : undefined; + const info = await transporter.sendMail({ + from: `${config.from_name} <${config.from_email}>`, + to, + cc: ccList, + subject, + html, + text: text || htmlToText(html), + attachments: atts, + }); + logger.info(`Manual email sent: ${info.messageId}`); + return { messageId: info.messageId, html }; +} + /** * Render a queued email's HTML WITHOUT sending it. Used by the Project * Overview cockpit to preview emails that predate the rendered_html column @@ -1105,6 +1134,7 @@ module.exports = { initializeTransporter, startEmailQueueProcessor, sendTemplateEmail, + sendRawEmail, renderQueuedEmail, processEmailQueue, queueEmail, diff --git a/frontend/src/pages/admin/messages/MessageComposer.tsx b/frontend/src/pages/admin/messages/MessageComposer.tsx new file mode 100644 index 00000000..21ca67ce --- /dev/null +++ b/frontend/src/pages/admin/messages/MessageComposer.tsx @@ -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(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 ( +
+
e.stopPropagation()}> +
+ {title || t('messages.compose', 'Compose message')} + +
+ +
+ + + +
+
+ {t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')} +
+
+
+
+ +
+ {t('messages.sendsFromHint', 'Sends from your configured outgoing address.')} +
+ + +
+
+
+
+ ); +}; + +export default MessageComposer; diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx index 61b37cc9..81c0e8f3 100644 --- a/frontend/src/pages/admin/messages/MessagesPage.tsx +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -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 = { }; 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(null); const [pdfDocId, setPdfDocId] = useState(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 = () => {
{ navigate('/admin/accounting/inbox')} + onCompose={(init, title) => setComposer({ init, title })} t={t} />
{pdfDocId != null && setPdfDocId(null)} t={t} />} + {composer && ( + setComposer(null)} + onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }} + t={t} + /> + )}
); }; @@ -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 = `


${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${it.from_address}:

`; + onCompose({ to: it.from_address || '', subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply')); + } + : undefined; + + // Create-from-template opens the composer with the rendered template loaded + // (freely editable); empty key = blank compose (gallery has no send template). + const onCreate = !isAcct + ? async (key: string, label: string) => { + if (!key) { onCompose({ to: recipient, subject: '', html: '' }, label); return; } + try { + const p = await emailService.previewTemplate(key, { customer_name: recipient.split('@')[0] || '' }, lang || 'en'); + onCompose({ to: recipient, subject: p.subject, html: p.body_html }, label); + } catch { + onCompose({ to: recipient, subject: label, html: '' }, label); + } + } + : undefined; + return (
- +
{selection.kind === 'queue' ? ( detailQuery.isLoading ? : detailQuery.data ? ( @@ -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 }) => ( - - ); +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 ( + + ); + }; + const create = (key: string, labelKey: string, fallback: string) => + onCreate ? () => onCreate(key, t(labelKey, fallback)) : undefined; return (
- {!readonly && } - {!readonly && } + + {isAcct ? ( @@ -486,10 +548,10 @@ const Toolbar: React.FC<{ isAcct: boolean; readonly: boolean; t: (k: string, d?: ) : ( <> - - - - + + + + )} diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index c9676d60..640cda41 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -15,6 +15,8 @@ export interface EmailQueueItem { eventId: number | null; eventName: string | null; eventSlug: string | null; + /** 'system' = app-generated (Automated), 'manual' = admin-composed (Customers Sent). */ + origin?: 'system' | 'manual'; } export interface EmailQueueListResponse { @@ -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 { + await api.post('/admin/email/send', payload); + }, + // Get all email templates async getTemplates(): Promise { const response = await api.get('/admin/email/templates');