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');
});
}
};