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:
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -529,6 +529,7 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
|
|||||||
router.get('/queue', adminAuth, requirePermission('email.view'), [
|
router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||||
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
|
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
|
||||||
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
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('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
query('from').optional({ values: 'falsy' }).isISO8601(),
|
query('from').optional({ values: 'falsy' }).isISO8601(),
|
||||||
query('to').optional({ values: 'falsy' }).isISO8601(),
|
query('to').optional({ values: 'falsy' }).isISO8601(),
|
||||||
@@ -547,6 +548,9 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
|||||||
const applyFilters = (qb) => {
|
const applyFilters = (qb) => {
|
||||||
if (req.query.status) qb.where('email_queue.status', req.query.status);
|
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);
|
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.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.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
|
||||||
if (req.query.q) {
|
if (req.query.q) {
|
||||||
@@ -575,6 +579,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
|||||||
'email_queue.sent_at',
|
'email_queue.sent_at',
|
||||||
'email_queue.error_message',
|
'email_queue.error_message',
|
||||||
'email_queue.retry_count',
|
'email_queue.retry_count',
|
||||||
|
'email_queue.origin',
|
||||||
'email_queue.event_id',
|
'email_queue.event_id',
|
||||||
'events.event_name as event_name',
|
'events.event_name as event_name',
|
||||||
'events.slug as event_slug'
|
'events.slug as event_slug'
|
||||||
@@ -594,6 +599,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
|||||||
sentAt: r.sent_at,
|
sentAt: r.sent_at,
|
||||||
errorMessage: r.error_message,
|
errorMessage: r.error_message,
|
||||||
retryCount: r.retry_count,
|
retryCount: r.retry_count,
|
||||||
|
origin: r.origin || 'system',
|
||||||
eventId: r.event_id,
|
eventId: r.event_id,
|
||||||
eventName: r.event_name || null,
|
eventName: r.event_name || null,
|
||||||
eventSlug: r.event_slug || 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
|
// Helper: parse variables JSON safely
|
||||||
function parseVariables(template) {
|
function parseVariables(template) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
|
* Render a queued email's HTML WITHOUT sending it. Used by the Project
|
||||||
* Overview cockpit to preview emails that predate the rendered_html column
|
* Overview cockpit to preview emails that predate the rendered_html column
|
||||||
@@ -1105,6 +1134,7 @@ module.exports = {
|
|||||||
initializeTransporter,
|
initializeTransporter,
|
||||||
startEmailQueueProcessor,
|
startEmailQueueProcessor,
|
||||||
sendTemplateEmail,
|
sendTemplateEmail,
|
||||||
|
sendRawEmail,
|
||||||
renderQueuedEmail,
|
renderQueuedEmail,
|
||||||
processEmailQueue,
|
processEmailQueue,
|
||||||
queueEmail,
|
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 { emailService, type ReceivedEmail } from '../../../services/email.service';
|
||||||
import { accountingService } from '../../../services/accounting.service';
|
import { accountingService } from '../../../services/accounting.service';
|
||||||
import { Loading } from '../../../components/common';
|
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
|
* has: the Automated stream (email_queue, incl. rendered bodies from migration
|
||||||
* 119) and the Accounting inbox (received_emails / supplier invoices). The
|
* 119) and the Accounting inbox (received_emails / supplier invoices). The
|
||||||
* Customers (hello@) mailbox and reply/compose land in later phases; those
|
* 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';
|
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[]; }
|
interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; }
|
||||||
|
|
||||||
type Selection =
|
type Selection =
|
||||||
@@ -57,11 +58,12 @@ const STATUS_STYLES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MessagesPage: React.FC = () => {
|
export const MessagesPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
const [pdfDocId, setPdfDocId] = useState<number | null>(null);
|
const [pdfDocId, setPdfDocId] = useState<number | null>(null);
|
||||||
|
const [composer, setComposer] = useState<{ init: ComposerInit; title?: string } | null>(null);
|
||||||
|
|
||||||
const queueQuery = useQuery({
|
const queueQuery = useQuery({
|
||||||
queryKey: ['messages', 'queue'],
|
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', 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-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',
|
{ id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'manual' },
|
||||||
note: t('messages.empty.customerSent', 'Your hand-written replies will appear here once compose ships (next phase).') },
|
|
||||||
] },
|
] },
|
||||||
{ id: 'acct', name: t('messages.account.accounting', 'Accounting'), addr: 'rechnungen@', color: '#12876a', folders: [
|
{ 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: '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', 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]);
|
], [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(() => {
|
const folder = useMemo(() => {
|
||||||
for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f };
|
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] };
|
return { a: accounts[0], f: accounts[0].folders[0] };
|
||||||
}, [accounts, activeFolder]);
|
}, [accounts, activeFolder]);
|
||||||
|
|
||||||
const countFor = (f: Folder): number | undefined => {
|
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.src === 'received') {
|
||||||
if (f.account === 'customers') return custTotal;
|
if (f.account === 'customers') return custTotal;
|
||||||
if (f.account === 'accounting') return acctTotal;
|
if (f.account === 'accounting') return acctTotal;
|
||||||
@@ -194,7 +203,7 @@ export const MessagesPage: React.FC = () => {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<MessageList
|
<MessageList
|
||||||
folder={folder.f}
|
folder={folder.f}
|
||||||
queue={queueQuery.data?.items}
|
queue={queueFor(folder.f.origin)}
|
||||||
received={receivedItems}
|
received={receivedItems}
|
||||||
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
|
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
@@ -209,14 +218,25 @@ export const MessagesPage: React.FC = () => {
|
|||||||
<ReadingPane
|
<ReadingPane
|
||||||
selection={selection}
|
selection={selection}
|
||||||
account={folder.a}
|
account={folder.a}
|
||||||
|
lang={i18n.language}
|
||||||
onViewDoc={setPdfDocId}
|
onViewDoc={setPdfDocId}
|
||||||
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
||||||
|
onCompose={(init, title) => setComposer({ init, title })}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pdfDocId != null && <PdfModal docId={pdfDocId} onClose={() => setPdfDocId(null)} t={t} />}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -306,10 +326,12 @@ const MessageList: React.FC<{
|
|||||||
const ReadingPane: React.FC<{
|
const ReadingPane: React.FC<{
|
||||||
selection: Selection;
|
selection: Selection;
|
||||||
account: Account;
|
account: Account;
|
||||||
|
lang: string;
|
||||||
onViewDoc: (id: number) => void;
|
onViewDoc: (id: number) => void;
|
||||||
onOpenAccounting: () => void;
|
onOpenAccounting: () => void;
|
||||||
|
onCompose: (init: ComposerInit, title?: string) => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: (k: string, d?: string) => string;
|
||||||
}> = ({ selection, account, onViewDoc, onOpenAccounting, t }) => {
|
}> = ({ selection, account, lang, onViewDoc, onOpenAccounting, onCompose, t }) => {
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
||||||
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
||||||
@@ -332,9 +354,38 @@ const ReadingPane: React.FC<{
|
|||||||
const isAcct = selection.kind === 'received'
|
const isAcct = selection.kind === 'received'
|
||||||
? selection.item.account_key !== 'customers'
|
? selection.item.account_key !== 'customers'
|
||||||
: account.id === 'acct';
|
: 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 (
|
return (
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<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">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{selection.kind === 'queue' ? (
|
{selection.kind === 'queue' ? (
|
||||||
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
||||||
@@ -461,22 +512,33 @@ const ReceivedDetail: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────── toolbar ──
|
// ─────────────────────────────────────────────────────────────── toolbar ──
|
||||||
const Toolbar: React.FC<{ isAcct: boolean; readonly: boolean; t: (k: string, d?: string) => string }> = ({ isAcct, readonly, t }) => {
|
const Toolbar: React.FC<{
|
||||||
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean }> = ({ icon: Icon, label, accent }) => (
|
isAcct: boolean;
|
||||||
<button
|
onReply?: () => void;
|
||||||
disabled
|
onCreate?: (key: string, label: string) => void;
|
||||||
title={t('messages.soon', 'Available in a later phase')}
|
t: (k: string, d?: string) => string;
|
||||||
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-lg text-[13px] font-medium cursor-not-allowed opacity-50 ${
|
}> = ({ isAcct, onReply, onCreate, t }) => {
|
||||||
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'
|
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
|
||||||
}`}
|
const enabled = !!onClick;
|
||||||
>
|
return (
|
||||||
<Icon className="w-[15px] h-[15px]" />{label}
|
<button
|
||||||
</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 (
|
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">
|
<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')} />}
|
<Tb icon={Reply} label={t('messages.reply', 'Reply')} onClick={onReply} />
|
||||||
{!readonly && <Tb icon={ReplyAll} label={t('messages.replyAll', 'Reply all')} />}
|
<Tb icon={ReplyAll} label={t('messages.replyAll', 'Reply all')} />
|
||||||
<Tb icon={Forward} label={t('messages.forward', 'Forward')} />
|
<Tb icon={Forward} label={t('messages.forward', 'Forward')} />
|
||||||
<span className="w-px h-5 bg-neutral-200 dark:bg-neutral-700 mx-1" />
|
<span className="w-px h-5 bg-neutral-200 dark:bg-neutral-700 mx-1" />
|
||||||
{isAcct ? (
|
{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={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={create('quote_sent', 'messages.createQuote', 'Quote')} />
|
||||||
<Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent />
|
<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 />
|
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={create('', 'messages.createGallery', 'Gallery')} />
|
||||||
<Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent />
|
<Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={create('invoice_sent', 'messages.createInvoice', 'Invoice')} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface EmailQueueItem {
|
|||||||
eventId: number | null;
|
eventId: number | null;
|
||||||
eventName: string | null;
|
eventName: string | null;
|
||||||
eventSlug: string | null;
|
eventSlug: string | null;
|
||||||
|
/** 'system' = app-generated (Automated), 'manual' = admin-composed (Customers Sent). */
|
||||||
|
origin?: 'system' | 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailQueueListResponse {
|
export interface EmailQueueListResponse {
|
||||||
@@ -244,6 +246,7 @@ export const emailService = {
|
|||||||
async listQueue(params: {
|
async listQueue(params: {
|
||||||
status?: EmailQueueStatus;
|
status?: EmailQueueStatus;
|
||||||
emailType?: string;
|
emailType?: string;
|
||||||
|
origin?: 'system' | 'manual';
|
||||||
q?: string;
|
q?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -260,6 +263,11 @@ export const emailService = {
|
|||||||
return response.data;
|
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
|
// Get all email templates
|
||||||
async getTemplates(): Promise<EmailTemplate[]> {
|
async getTemplates(): Promise<EmailTemplate[]> {
|
||||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||||
|
|||||||
Reference in New Issue
Block a user