-
- {t('messages.from', 'from')}
{item.from_address || '—'}
- {' · '}{t('messages.to', 'to')}
rechnungen@
+}> = ({ item, 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
+ || (item.account_key === 'customers' ? 'hello@' : 'rechnungen@');
+ return (
+ <>
+
+ {item.subject || t('messages.noSubject', '(no subject)')}
+
+
+
+ {t('messages.from', 'from')} {item.from_address || '—'}
+ {' · '}{t('messages.to', 'to')} {toAddr}
+
+
{fmt(item.received_at)}
-
{fmt(item.received_at)}
-
-
- {t('messages.inboundBodyPending', 'Full message bodies are captured from the next phase. For now this shows the envelope and any attached document.')}
-
+ {detail.isLoading ? (
+
+ ) : detail.data?.body_html ? (
+
+ {/* Sanitized server-side; rendered with a strict (script-less, no
+ same-origin) sandbox as a second layer against untrusted mail. */}
+
+
+ ) : detail.data?.body_text ? (
+
{detail.data.body_text}
+ ) : (
+
+ {t('messages.noInboundBody', 'No message body was captured for this email.')}
+
+ )}
- {item.inbound_document_id != null && (
-
-
-
-
- )}
- {item.error && (
-
{item.error}
- )}
- >
-);
+ {item.inbound_document_id != null && (
+
+
+
+
+ )}
+ {item.error && (
+
{item.error}
+ )}
+ >
+ );
+};
// ─────────────────────────────────────────────────────────────── toolbar ──
const Toolbar: React.FC<{ isAcct: boolean; readonly: boolean; t: (k: string, d?: string) => string }> = ({ isAcct, readonly, t }) => {
diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts
index 8d72452b..c9676d60 100644
--- a/frontend/src/services/email.service.ts
+++ b/frontend/src/services/email.service.ts
@@ -125,7 +125,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;
@@ -134,11 +136,31 @@ 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;
+ enabled?: boolean;
+}
+
export const emailService = {
// Get email configuration
async getConfig(): Promise
{
@@ -181,10 +203,26 @@ export const emailService = {
const response = await api.post('/admin/email/incoming-config/poll', {});
return response.data;
},
- async listReceived(params: { page?: number; pageSize?: number } = {}): Promise {
+ async listReceived(params: { page?: number; pageSize?: number; account?: string } = {}): Promise {
const response = await api.get('/admin/email/received', { params });
return response.data;
},
+ async getReceivedItem(id: number): Promise {
+ const response = await api.get(`/admin/email/received/${id}`);
+ return response.data;
+ },
+ // Additional inbound mailboxes (e.g. the customer hello@ box).
+ async listMailAccounts(): Promise {
+ const response = await api.get<{ items: MailAccount[] }>('/admin/email/accounts');
+ return response.data.items;
+ },
+ async saveMailAccount(account: MailAccount): Promise {
+ await api.post('/admin/email/accounts', account);
+ },
+ async testMailAccount(account: Partial): Promise {
+ const response = await api.post('/admin/email/accounts/test', account);
+ return response.data;
+ },
// Test email configuration
async testEmail(testEmail: string): Promise {
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 3/3] =?UTF-8?q?feat(messages):=20Phase=203=20=E2=80=94=20e?=
=?UTF-8?q?ditable-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 = `
-
+
{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');