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 ? (