From f02fba63325115fe416366c5d1c4f38900386cce Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:47:43 +0200 Subject: [PATCH] fix(projects): scope email rollup to CRM types + re-render unstored previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- backend/src/services/emailProcessor.js | 17 ++++++ backend/src/services/projectService.js | 55 +++++++++++++++---- frontend/src/i18n/locales/de.json | 3 +- frontend/src/i18n/locales/en.json | 3 +- .../admin/projects/ProjectCockpitPage.tsx | 9 ++- frontend/src/services/projects.service.ts | 3 + 6 files changed, 76 insertions(+), 14 deletions(-) diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 2539dcc5..3eabae95 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -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. // // Options: @@ -1074,6 +1090,7 @@ module.exports = { initializeTransporter, startEmailQueueProcessor, sendTemplateEmail, + renderQueuedEmail, processEmailQueue, queueEmail, stopEmailQueueProcessor, diff --git a/backend/src/services/projectService.js b/backend/src/services/projectService.js index 38c260ea..f7cefeb1 100644 --- a/backend/src/services/projectService.js +++ b/backend/src/services/projectService.js @@ -230,15 +230,30 @@ async function getProjectOverview(id, perms = {}) { const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } }; // Emails — newest first. rendered_html presence flagged, body fetched - // lazily by the preview endpoint. Gallery/event mails carry event_id; - // CRM document mails (quote_sent, invoice_*, contract_*) are queued with - // event_id=null, so we also match the project customer's email address. + // lazily by the preview endpoint. Gallery/event mails carry event_id; CRM + // document mails (quote_/contract_/invoice_/storno_) are queued with + // 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; if (eventIds.length || customerEmail) { const emails = await db('email_queue') .where(function () { 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') .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 - * before the rendered_html column existed have none → `available:false`, the - * frontend then shows a "preview not stored" note rather than a stale - * re-render. + * HTML preview for an email_queue row (cockpit). Prefers the exact bytes + * stored at send time (rendered_html). Rows sent before that column existed + * have none — we then RE-RENDER from the current template + the row's stored + * 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) { const row = await db('email_queue') .where({ id: emailId }) - .select('id', 'recipient_email', 'email_type', 'status', 'rendered_html') + .select('id', 'recipient_email', 'email_type', 'status', 'rendered_html', 'email_data') .first(); 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 { id: row.id, recipient: row.recipient_email, type: row.email_type, status: row.status, - available: !!row.rendered_html, - html: row.rendered_html || null, + available: !!html, + exact: false, + html, }; } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 44123a08..7ca81d4d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3439,7 +3439,8 @@ "cancel": "Abbrechen", "retry": "Wiederholen", "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": { "created": "Projekt erstellt", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d2e1c44d..cda08043 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3439,7 +3439,8 @@ "cancel": "Cancel", "retry": "Retry", "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": { "created": "Project created", diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx index b24c6d61..a81fbcd6 100644 --- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx +++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx @@ -404,7 +404,14 @@ export const ProjectCockpitPage: React.FC = () => { {previewLoading ? ( ) : preview && preview.available && preview.html ? ( -