fix(cms): hardcode styled email templates
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
|
||||
@@ -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;"> </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(
|
||||
|
||||
Reference in New Issue
Block a user