fix(cms): hardcode styled email templates

This commit is contained in:
2026-10-07 09:33:39 +02:00
parent dcf4c85128
commit 47d2a7fc3e
11 changed files with 70 additions and 309 deletions
+7 -10
View File
@@ -11,19 +11,18 @@
* retried by nothing today but is visible in the admin panel as a null.
*
* The (Get Involved) section form can submit an application with no
* opportunity relation. The mail then drops the call name and stays a plain
* acknowledgement, so the promise holds for both entry points.
* opportunity relation. The mail then drops the call name and uses the
* no-call acknowledgement, so the promise holds for both entry points.
*
* COPY IS CMS-DRIVEN like the event mails: subject and body come from the
* `email-settings` single type's `application-acknowledgement` template, whose
* shipped default is the approved wording. The call name fills the `{call}`
* Copy is hardcoded in `email-template-defaults.ts`, and this message uses the
* same styled HTML layout as the event emails. The call name fills the `{call}`
* placeholder; when there is no call name the placeholder and its preceding
* word collapse, leaving the no-call variant automatically.
*/
import type { Core } from '@strapi/strapi';
import { resolveEmailTemplate } from './email-templates';
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
@@ -85,10 +84,8 @@ export async function sendApplicationAcknowledgement({
opportunityReference: OpportunityReference;
}): Promise<void> {
try {
const [opportunity, template] = await Promise.all([
fetchBilingualOpportunity(strapi, opportunityReference),
resolveEmailTemplate(strapi, 'application-acknowledgement'),
]);
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
const template = DEFAULT_EMAIL_TEMPLATES['application-acknowledgement'];
const callEn = opportunity.en?.title || undefined;
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
+7 -11
View File
@@ -1,12 +1,9 @@
/**
* email-template-defaults — the shipped subject and body for every email the
* CMS sends.
* email-template-defaults — the hardcoded subject and body for every email
* the CMS sends.
*
* These are the "default for each email template": first-boot writes them into
* the `email-settings` single type (src/bootstrap/email-settings.ts) so editors
* start from a working, editable baseline, and they remain the fallback in code
* when the single type is missing, unreadable or a template is switched off —
* which is what keeps the mails sending even if nothing was ever configured.
* This is the source of truth for outbound email copy. Changing a template
* requires a code change and review; the CMS does not store editable copies.
*
* The copy is the wording the mail modules hard-coded before this file existed,
* which itself mirrors the approved web copy (apps/web i18n messages and
@@ -37,8 +34,8 @@ export type LocalisedEmailTemplate = {
/**
* The two approved acknowledgement wordings, kept for their exact strings:
* the with-call variant is the shipped default and the no-call variant is what
* the placeholder collapse reproduces when there is no opportunity.
* the no-call variant is what placeholder collapse produces when there is no
* opportunity.
*/
const ACKNOWLEDGEMENT_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
@@ -46,8 +43,7 @@ const ACKNOWLEDGEMENT_WITH_CALL = {
} as const;
/**
* The plain acknowledgement (no call), reproduced verbatim so editors can
* compare against the collapsed form when the placeholder is empty.
* The plain acknowledgement (no call), reproduced verbatim for reference.
*/
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
+2 -47
View File
@@ -1,53 +1,8 @@
/**
* email-templates — resolve hardcoded copy and shared placeholders for the
* outbound mails.
*
* The subjects and markdown bodies live in email-template-defaults.ts. This
* resolver returns those values directly; the CMS has no runtime copy source.
*
* resolveEmailTemplate the subject/body pair for a key, per locale, each
* locale falling back to its own code default when
* the row is missing, switched off or unreadable.
* fillPlaceholders the {event} / {date} / {call} substitution, shared
* by both mail modules. An empty value removes the
* placeholder together with the word before it, so
* "for {call}" / "على {call}" collapse cleanly when
* there is no call (see the component description).
* email-templates — shared placeholder interpolation for hardcoded outbound
* email templates. Subjects and bodies live in email-template-defaults.ts.
*/
import type { Core } from '@strapi/strapi';
import {
DEFAULT_EMAIL_TEMPLATES,
type EmailTemplateKey,
type LocalisedEmailTemplate,
} from './email-template-defaults';
/* ---------------------------------------------------------------------------
TEMPLATES
------------------------------------------------------------------------ */
/** The hardcoded per-locale subject/body for one template key. */
export type ResolvedEmailTemplate = {
ar: LocalisedEmailTemplate;
en: LocalisedEmailTemplate;
};
/**
* The subject and body for one template key, per locale. The retained Strapi
* argument keeps callers stable while all senders move to the static source.
*/
export function resolveEmailTemplate(
_strapi: Core.Strapi,
key: EmailTemplateKey
): ResolvedEmailTemplate {
return DEFAULT_EMAIL_TEMPLATES[key];
}
/* ---------------------------------------------------------------------------
PLACEHOLDERS
------------------------------------------------------------------------ */
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
export type PlaceholderValues = Readonly<Record<string, string>>;
+50 -42
View File
@@ -14,23 +14,16 @@
* The Arabic block always comes first and is marked dir="rtl"; the English
* block follows inside the same message. Subjects are bilingual too.
*
* COPY IS CMS-DRIVEN. The subject and body for each mail come from the
* `email-settings` single type — markdown bodies, converted to HTML at send
* time (see lib/markdown.ts) — with the shipped approved copy as the fallback
* (see lib/email-template-defaults.ts). This module owns the chrome around
* that copy: the event lookups, the {event} / {date} placeholders, the venue
* or join-online line, and the logo header/footer (SHARP-ER logo, EU badge
* and the consortium partner logos, read from `site-settings`).
* COPY IS HARDCODED in lib/email-template-defaults.ts. This module owns the
* shared styled HTML layout, event lookups, the {event} / {date} placeholders,
* venue or join-online line, and EU/partner branding.
*/
import type { Core } from '@strapi/strapi';
import { buildEventCalendar } from './event-calendar';
import {
resolveEmailTemplate,
fillPlaceholders,
type PlaceholderValues,
} from './email-templates';
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { fillPlaceholders, type PlaceholderValues } from './email-templates';
import { mdToHtml, mdToText, escapeHtml } from './markdown';
const EVENT_UID = 'api::event.event';
@@ -401,27 +394,46 @@ export function buildBilingualMail(opts: {
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
);
const html = `
const subject = `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`;
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(subject)}</title>
${renderJsonLd(jsonLd)}
${renderBrandHeader()}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
<div dir="rtl" lang="ar">
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
${locationHtml(locationLine?.ar)}
</div>
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" />
<div dir="ltr" lang="en">
${fillPlaceholders(mdToHtml(bodyEn), escaped(varsEn))}
${locationHtml(locationLine?.en)}
</div>
</td></tr></table>
<p style="margin-top:32px; font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
${FOOTER.ar}
</p>
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
${FOOTER.en}
</p>
${renderPartnerLogos()}`;
</head>
<body style="margin:0; padding:0; background-color:#f2f5f3; ${BASE_FONT}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#f2f5f3" style="width:100%; border-collapse:collapse; background-color:#f2f5f3;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="width:100%; max-width:640px; border:1px solid #dfe6e1; border-top:4px solid #356b57; border-radius:8px; background-color:#ffffff; border-collapse:separate; border-spacing:0;">
<tr><td style="padding:28px 32px 24px; ${BASE_FONT}">
${renderBrandHeader()}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.7; color:#25312b; font-size:16px;">
<div dir="rtl" lang="ar" style="direction:rtl; text-align:right;">
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
${locationHtml(locationLine?.ar)}
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0;"><tr><td style="border-top:1px solid #e2e8e4; font-size:0; line-height:0;">&nbsp;</td></tr></table>
<div dir="ltr" lang="en" style="direction:ltr; text-align:left;">
${fillPlaceholders(mdToHtml(bodyEn), escaped(varsEn))}
${locationHtml(locationLine?.en)}
</div>
</td></tr></table>
<p style="margin:32px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.ar}
</p>
<p style="margin:8px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.en}
</p>
${renderPartnerLogos()}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = [
fillPlaceholders(mdToText(bodyAr), varsAr),
@@ -437,7 +449,7 @@ export function buildBilingualMail(opts: {
.join('\n');
return {
subject: `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`,
subject,
text,
html,
};
@@ -489,7 +501,7 @@ type MailOptions = {
to: string;
subject: string;
text: string;
html?: string;
html: string;
attachments?: MailAttachment[];
};
@@ -553,10 +565,8 @@ export async function sendRegistrationConfirmation({
try {
const isWaitlist = state === 'waitlisted';
const [event, template] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'),
]);
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES[isWaitlist ? 'waitlist' : 'confirmation'];
if (!event.en && !event.ar) {
strapi.log.warn(
@@ -623,10 +633,8 @@ export async function sendEventReminderMail({
reservation: ReservationIdentity;
}): Promise<boolean> {
try {
const [event, template] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, 'event-reminder'),
]);
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES['event-reminder'];
if (!event.en && !event.ar) {
strapi.log.warn(