fix(projects): scope email rollup to CRM types + re-render unstored previews

- The customer address often doubles as the admin notification target, so
  matching emails purely by recipient swept in system alerts (backup_failed,
  restore_failed, …). The recipient match is now restricted to CRM document
  types (quote_/contract_/invoice_/storno_); event-scoped mails still match
  by event_id.
- getEmailPreview now falls back to renderQueuedEmail() — re-rendering from
  the current template + the row's stored email_data — for emails sent before
  rendered_html capture, flagged exact:false with an amber 're-rendered' note.
  Only a missing template / no variables falls through to 'nothing stored'.

Known limitation: a customer with multiple projects sees their event_id=null
CRM mails under each (email_queue has no project_id).
This commit is contained in:
Luca
2026-06-06 22:47:43 +02:00
parent c0b6d14d08
commit f02fba6332
6 changed files with 76 additions and 14 deletions
+17
View File
@@ -772,6 +772,22 @@ async function sendTemplateEmail(to, templateKey, variables) {
} }
} }
/**
* Render a queued email's HTML WITHOUT sending it. Used by the Project
* Overview cockpit to preview emails that predate the rendered_html column
* (so nothing was stored at send time). The result is rendered from the
* CURRENT template + the row's stored variables, so it's a faithful
* approximation rather than the exact bytes that were sent — callers flag
* it as a re-render. Returns null when the template no longer exists.
*/
async function renderQueuedEmail(templateKey, variables = {}, to = '') {
const template = await db('email_templates').where('template_key', templateKey).first();
if (!template) return null;
const language = await getRecipientLanguage(to, variables.eventId || null);
const { subject, htmlBody } = await processTemplate(template, variables, language);
return { subject, html: htmlBody };
}
// Process email queue. // Process email queue.
// //
// Options: // Options:
@@ -1074,6 +1090,7 @@ module.exports = {
initializeTransporter, initializeTransporter,
startEmailQueueProcessor, startEmailQueueProcessor,
sendTemplateEmail, sendTemplateEmail,
renderQueuedEmail,
processEmailQueue, processEmailQueue,
queueEmail, queueEmail,
stopEmailQueueProcessor, stopEmailQueueProcessor,
+44 -11
View File
@@ -230,15 +230,30 @@ async function getProjectOverview(id, perms = {}) {
const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } }; const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } };
// Emails — newest first. rendered_html presence flagged, body fetched // Emails — newest first. rendered_html presence flagged, body fetched
// lazily by the preview endpoint. Gallery/event mails carry event_id; // lazily by the preview endpoint. Gallery/event mails carry event_id; CRM
// CRM document mails (quote_sent, invoice_*, contract_*) are queued with // document mails (quote_/contract_/invoice_/storno_) are queued with
// event_id=null, so we also match the project customer's email address. // event_id=null, so we ALSO match the project customer's address — but
// ONLY for those CRM document types. Without that type filter, system /
// admin alerts (backup_failed, restore_failed, …) sent to the same inbox
// (the customer address often doubles as the admin notification target)
// would wrongly surface under the project.
const customerEmail = project.customerEmail || null; const customerEmail = project.customerEmail || null;
if (eventIds.length || customerEmail) { if (eventIds.length || customerEmail) {
const emails = await db('email_queue') const emails = await db('email_queue')
.where(function () { .where(function () {
if (eventIds.length) this.whereIn('event_id', eventIds); if (eventIds.length) this.whereIn('event_id', eventIds);
if (customerEmail) this.orWhere('recipient_email', customerEmail); if (customerEmail) {
this.orWhere(function () {
this.where('recipient_email', customerEmail).andWhere(function () {
// LIKE '_' is a single-char wildcard that matches the literal
// underscore in every CRM type; no escape clause needed and no
// real type collides with the trailing '%'.
for (const prefix of ['quote_%', 'contract_%', 'invoice_%', 'storno_%']) {
this.orWhere('email_type', 'like', prefix);
}
});
});
}
}) })
.select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id') .select('id', 'recipient_email', 'email_type', 'status', 'created_at', 'sent_at', 'error_message', 'event_id')
.orderBy('created_at', 'desc') .orderBy('created_at', 'desc')
@@ -311,24 +326,42 @@ async function getProjectOverview(id, perms = {}) {
} }
/** /**
* The ACTUAL sent HTML for an email_queue row (cockpit preview). Rows sent * HTML preview for an email_queue row (cockpit). Prefers the exact bytes
* before the rendered_html column existed have none → `available:false`, the * stored at send time (rendered_html). Rows sent before that column existed
* frontend then shows a "preview not stored" note rather than a stale * have none — we then RE-RENDER from the current template + the row's stored
* re-render. * variables (email_data) so the admin still sees the email, flagged `exact:
* false`. Only when even re-rendering fails (template gone / no variables)
* does `available:false` fall through to the "nothing stored" note.
*/ */
async function getEmailPreview(emailId) { async function getEmailPreview(emailId) {
const row = await db('email_queue') const row = await db('email_queue')
.where({ id: emailId }) .where({ id: emailId })
.select('id', 'recipient_email', 'email_type', 'status', 'rendered_html') .select('id', 'recipient_email', 'email_type', 'status', 'rendered_html', 'email_data')
.first(); .first();
if (!row) throw new AppError('Email not found', 404); if (!row) throw new AppError('Email not found', 404);
if (row.rendered_html) {
return { id: row.id, recipient: row.recipient_email, type: row.email_type, status: row.status, available: true, exact: true, html: row.rendered_html };
}
// Fallback: re-render from the current template + stored variables.
let html = null;
try {
let variables = row.email_data;
if (typeof variables === 'string') variables = JSON.parse(variables);
const { renderQueuedEmail } = require('./emailProcessor');
const rendered = await renderQueuedEmail(row.email_type, variables || {}, row.recipient_email);
html = rendered && rendered.html ? rendered.html : null;
} catch (_) { html = null; }
return { return {
id: row.id, id: row.id,
recipient: row.recipient_email, recipient: row.recipient_email,
type: row.email_type, type: row.email_type,
status: row.status, status: row.status,
available: !!row.rendered_html, available: !!html,
html: row.rendered_html || null, exact: false,
html,
}; };
} }
+2 -1
View File
@@ -3439,7 +3439,8 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"retry": "Wiederholen", "retry": "Wiederholen",
"previewTitle": "E-Mail-Vorschau", "previewTitle": "E-Mail-Vorschau",
"noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden." "noPreview": "Keine gespeicherte Vorschau für diese E-Mail — sie wurde gesendet, bevor Vorschauen erfasst wurden.",
"reRendered": "Neu gerendert aus der aktuellen Vorlage — diese E-Mail wurde gesendet, bevor Vorschauen erfasst wurden, und kann daher leicht von der tatsächlich versendeten abweichen."
}, },
"toast": { "toast": {
"created": "Projekt erstellt", "created": "Projekt erstellt",
+2 -1
View File
@@ -3439,7 +3439,8 @@
"cancel": "Cancel", "cancel": "Cancel",
"retry": "Retry", "retry": "Retry",
"previewTitle": "Email preview", "previewTitle": "Email preview",
"noPreview": "No stored preview for this email — it was sent before previews were captured." "noPreview": "No stored preview for this email — it was sent before previews were captured.",
"reRendered": "Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received."
}, },
"toast": { "toast": {
"created": "Project created", "created": "Project created",
@@ -404,7 +404,14 @@ export const ProjectCockpitPage: React.FC = () => {
{previewLoading ? ( {previewLoading ? (
<Loading /> <Loading />
) : preview && preview.available && preview.html ? ( ) : preview && preview.available && preview.html ? (
<iframe title="email-preview" srcDoc={preview.html} className="w-full h-[60vh] border border-neutral-200 dark:border-neutral-700 rounded bg-white" /> <>
{!preview.exact && (
<div className="mb-3 rounded-md bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
{t('projects.email.reRendered', 'Re-rendered from the current template — this email was sent before previews were captured, so it may differ slightly from what the recipient received.')}
</div>
)}
<iframe title="email-preview" srcDoc={preview.html} className="w-full h-[60vh] border border-neutral-200 dark:border-neutral-700 rounded bg-white" />
</>
) : ( ) : (
<div className="text-center py-10 text-neutral-500"> <div className="text-center py-10 text-neutral-500">
{t('projects.email.noPreview', 'No stored preview for this email — it was sent before previews were captured.')} {t('projects.email.noPreview', 'No stored preview for this email — it was sent before previews were captured.')}
@@ -118,6 +118,9 @@ export interface EmailPreview {
type: string; type: string;
status: string; status: string;
available: boolean; available: boolean;
/** true = exact bytes stored at send time; false = re-rendered from the
* current template (approximation for emails sent before capture). */
exact: boolean;
html: string | null; html: string | null;
} }