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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
<>
|
||||||
|
{!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" />
|
<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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user