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,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');
});
}
};
+57
View File
@@ -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 {
+30
View File
@@ -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,
@@ -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 }) => (
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
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'
}`}
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');