feat: email templates in CMS

This commit is contained in:
2026-09-24 14:20:24 +02:00
parent fb097d22d9
commit 60dd92e4b0
19 changed files with 1100 additions and 232 deletions
+27 -34
View File
@@ -14,12 +14,19 @@
* opportunity relation. The mail then drops the call name and stays a plain
* acknowledgement, so the promise holds for both entry points.
*
* Copy is derived from the approved web copy; the footer is the same
* EU-visibility block the event mails use. Nothing here is invented.
* 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}`
* 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 {
readEmailBranding,
resolveEmailTemplate,
} from './email-templates';
import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
@@ -33,31 +40,6 @@ type OpportunityReference = { documentId?: string; id?: number } | undefined;
/** An opportunity looked up in both locales; missing locales are allowed. */
type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } };
const ACKNOWLEDGEMENT_SUBJECT = {
ar: 'تم استلام الطلب',
en: 'Application received',
} as const;
/*
* The approved wording this mirrors, from
* apps/web/app/routes/_events-opportunities/copy.ts:
* applicationReceivedBody en: "Thank you for your application. Our team
* will review it and follow up by email."
* ar: "شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل
* معك عبر البريد الإلكتروني."
* receivedBody ar: "شكرًا لتقديمك على {route}..." (the "على"
* preposition is kept for the call name).
*/
const ACKNOWLEDGEMENT_BODY_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application for {call}. Our team will review it and follow up by email.',
} as const;
const ACKNOWLEDGEMENT_BODY = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
} as const;
/**
* Fetch an opportunity in both locales. Only published entries are ever
* fetched; either a documentId or a legacy numeric id is accepted, mirroring
@@ -89,6 +71,10 @@ export async function fetchBilingualOpportunity(
* `acknowledgementSentAt` when the provider accepted it. Best-effort and
* never awaited by the request path: a slow or failing SMTP call must not make
* the application POST fail.
*
* The `{call}` placeholder is filled from the opportunity's title in each
* locale; an empty title collapses "for {call}" / "على {call}" out of the
* body, which is how a no-opportunity application still reads cleanly.
*/
export async function sendApplicationAcknowledgement({
strapi,
@@ -102,18 +88,25 @@ export async function sendApplicationAcknowledgement({
opportunityReference: OpportunityReference;
}): Promise<void> {
try {
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
const [opportunity, template, branding] = await Promise.all([
fetchBilingualOpportunity(strapi, opportunityReference),
resolveEmailTemplate(strapi, 'application-acknowledgement'),
readEmailBranding(strapi),
]);
const callEn = opportunity.en?.title || undefined;
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
const mail = buildBilingualMail({
subjectAr: ACKNOWLEDGEMENT_SUBJECT.ar,
subjectEn: ACKNOWLEDGEMENT_SUBJECT.en,
bodyAr: (callAr ? ACKNOWLEDGEMENT_BODY_WITH_CALL.ar : ACKNOWLEDGEMENT_BODY.ar)
.replace(/\{call\}/g, callAr ?? ''),
bodyEn: (callEn ? ACKNOWLEDGEMENT_BODY_WITH_CALL.en : ACKNOWLEDGEMENT_BODY.en)
.replace(/\{call\}/g, callEn ?? ''),
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { call: callAr ?? '' },
en: { call: callEn ?? '' },
},
branding,
});
const ok = await trySendMail(strapi, { to, ...mail });
+102
View File
@@ -0,0 +1,102 @@
/**
* email-template-defaults — the shipped 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.
*
* 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
* _events-opportunities/copy.ts). Nothing here is invented.
*
* BODIES ARE MARKDOWN. Plain paragraphs convert unchanged; the `{event}`,
* `{date}` and `{call}` placeholders are filled after conversion. A placeholder
* whose value is empty collapses together with the word before it, so the call
* clause of the acknowledgement ("for {call}" / "على {call}") reads cleanly even
* when the submission carries no opportunity.
*/
export const EMAIL_TEMPLATE_KEYS = [
'confirmation',
'waitlist',
'event-reminder',
'application-acknowledgement',
] as const;
export type EmailTemplateKey = (typeof EMAIL_TEMPLATE_KEYS)[number];
/** One locale's worth of a template. */
export type LocalisedEmailTemplate = {
subject: string;
/** Markdown; converted to HTML at send time. */
body: string;
};
/**
* 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.
*/
const ACKNOWLEDGEMENT_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application for {call}. Our team will review it and follow up by email.',
} as const;
/**
* The plain acknowledgement (no call), reproduced verbatim so editors can
* compare against the collapsed form when the placeholder is empty.
*/
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
} as const;
export const ACKNOWLEDGEMENT_WITHOUT_CALL_COPY = ACKNOWLEDGEMENT_WITHOUT_CALL;
export const DEFAULT_EMAIL_TEMPLATES: Readonly<
Record<EmailTemplateKey, { ar: LocalisedEmailTemplate; en: LocalisedEmailTemplate }>
> = {
confirmation: {
ar: {
subject: 'تم تأكيد تسجيلك',
body: 'شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.',
},
en: {
subject: 'Registration confirmed',
body: 'Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.',
},
},
waitlist: {
ar: {
subject: 'تمت إضافتك إلى قائمة الانتظار',
body: 'شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.',
},
en: {
subject: "You're on the waitlist",
body: 'Thank you — {event} on {date} is fully booked, so we have added you to the waitlist. We will email you if a place becomes available.',
},
},
'event-reminder': {
ar: {
subject: 'تذكير: {event}',
body: 'هذا تذكير بأنك مسجّل في {event} بتاريخ {date}. تبدأ الفعالية في الوقت المُشار إليه أدناه.',
},
en: {
subject: 'Reminder: {event}',
body: 'This is a reminder that you are registered for {event} on {date}. A summary is below.',
},
},
'application-acknowledgement': {
ar: {
subject: 'تم استلام الطلب',
body: ACKNOWLEDGEMENT_WITH_CALL.ar,
},
en: {
subject: 'Application received',
body: ACKNOWLEDGEMENT_WITH_CALL.en,
},
},
};
+295
View File
@@ -0,0 +1,295 @@
/**
* email-templates — resolve the CMS-configured copy and branding the outbound
* mails are built from.
*
* The `email-settings` single type (created at boot with the shipped defaults)
* holds one subject + markdown body per template key, per locale. These
* resolvers turn that editable state into exactly the values an email needs:
*
* 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.
* readEmailBranding the logos for the mail: the SHARP-ER project logo,
* the EU badge and the consortium partner logos,
* preferring the media configured on `site-settings`
* and falling back to the brand assets bundled with
* the CMS (public/branding), so the mail brands itself
* the same way the site does even before anyone
* uploads media. Logo images need an absolute URL, so
* they resolve against `server.url` (PUBLIC_URL, with
* the local server as the development fallback); with
* neither available the mail falls back to the site
* name as a text wordmark.
* 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).
*/
import type { Core } from '@strapi/strapi';
import {
DEFAULT_EMAIL_TEMPLATES,
type EmailTemplateKey,
type LocalisedEmailTemplate,
} from './email-template-defaults';
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
const SITE_SETTINGS_UID = 'api::site-settings.site-settings';
const LOG_PREFIX = '[email-templates]';
/* ---------------------------------------------------------------------------
TEMPLATES
------------------------------------------------------------------------ */
type MailTemplateSettings = {
templates?: unknown;
};
/**
* Find the row for `key` on a `email-settings` document read for one locale.
* Returns `undefined` when there is no such row, it is switched off, or its
* fields are unusable — the caller then falls back to that locale's default.
*/
function templateRowFor(
settings: unknown,
key: EmailTemplateKey
): LocalisedEmailTemplate | undefined {
if (!settings || typeof settings !== 'object') return undefined;
const templates = (settings as MailTemplateSettings).templates;
if (!Array.isArray(templates)) return undefined;
for (const row of templates) {
if (!row || typeof row !== 'object') continue;
const candidate = row as {
key?: unknown;
enabled?: unknown;
subject?: unknown;
body?: unknown;
};
if (candidate.key !== key) continue;
if (candidate.enabled === false) return undefined;
if (typeof candidate.subject === 'string' && typeof candidate.body === 'string') {
return { subject: candidate.subject, body: candidate.body };
}
return undefined;
}
return undefined;
}
/** The per-locale subject/body for one template key, resolved from the CMS. */
export type ResolvedEmailTemplate = {
ar: LocalisedEmailTemplate;
en: LocalisedEmailTemplate;
};
/**
* The subject and body for one template key, per locale. Each locale prefers
* its own CMS row and falls back to that locale's shipped default when the
* row is missing, disabled or the single type is unreadable — so a broken or
* empty `email-settings` never stops a mail from going out.
*/
export async function resolveEmailTemplate(
strapi: Core.Strapi,
key: EmailTemplateKey
): Promise<ResolvedEmailTemplate> {
const defaults = DEFAULT_EMAIL_TEMPLATES[key];
const resolved: ResolvedEmailTemplate = {
ar: { subject: defaults.ar.subject, body: defaults.ar.body },
en: { subject: defaults.en.subject, body: defaults.en.body },
};
try {
const [arSettings, enSettings] = await Promise.all([
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'ar' }),
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'en' }),
]);
const arRow = templateRowFor(arSettings, key);
const enRow = templateRowFor(enSettings, key);
if (arRow) resolved.ar = arRow;
if (enRow) resolved.en = enRow;
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read email-settings for "${key}", using defaults: ${String(error)}`
);
}
return resolved;
}
/* ---------------------------------------------------------------------------
BRANDING (SHARP-ER + EU + consortium logos)
------------------------------------------------------------------------ */
/**
* The `site-settings` media shapes, read with a loose internal eye so the
* population details of the findFirst populate above cannot break the mail.
*/
type RawMedia = { url?: unknown } | null | undefined;
type RawPartner = { name?: unknown; logo?: RawMedia } | undefined;
/** A partner that will appear in the mail's logo block. */
export type EmailPartner = { name: string; logo: string | null };
/** Everything the mail chrome needs, resolved to absolute image URLs. */
export type EmailBranding = {
siteName: string;
projectLogo: string | null;
euBadge: string | null;
partners: EmailPartner[];
};
const EMPTY_BRANDING: EmailBranding = {
siteName: 'SHARP-ER',
projectLogo: null,
euBadge: null,
partners: [],
};
/**
* The brand assets shipped with the CMS, copied from the site's own
* `apps/web/public/assets` into `public/branding`. They are the fallback when
* `site-settings` has no logo uploaded yet, so every mail still carries the
* SHARP-ER wordmark, the EU emblem and the consortium logos (Acted, UEC, SBE —
* always together, never one partner alone). `site-settings` media still wins
* when an editor uploads it.
*/
const BUNDLED_BRANDING = {
projectLogo: '/branding/sharp-er-wordmark.png',
euBadge: '/branding/eu-emblem.png',
partners: [
{ name: 'Acted', logo: '/branding/partner-acted.png' },
{ name: 'United Efforts Consultancy (UEC)', logo: '/branding/partner-uec.png' },
{ name: 'Stichting Bureau Ensemble (SBE)', logo: '/branding/partner-sbe.png' },
],
} as const;
/**
* The absolute origin the brand images are resolved against: `server.url`
* (PUBLIC_URL) when configured, the local server in development, and `null`
* otherwise. Mail clients need absolute URLs or the logos do not load; on
* production behind a custom domain, leaving PUBLIC_URL unset drops the
* images and degrades the mail to the plain text wordmark.
*/
const emailBase = (strapi: Core.Strapi): string | null => {
const configured = strapi.config.get('server.url') as string | undefined;
if (configured && configured.trim().length > 0) return configured;
if ((process.env.NODE_ENV ?? 'development') !== 'production') {
return `http://localhost:${strapi.config.get('server.port') ?? 1337}`;
}
return null;
};
/** Resolve a media URL to an absolute one, or null when that is impossible. */
const absoluteMediaUrl = (base: string | undefined, url: unknown): string | null => {
if (typeof url !== 'string' || url.length === 0) return null;
try {
return new URL(url, base).href;
} catch {
return null;
}
};
/**
* Read the logos for the mail chrome: the SHARP-ER project logo, the EU
* funding badge and the consortium partner logos (Acted, UEC, SBE). It prefers
* the `site-settings` media and falls back to the brand assets bundled with the
* CMS (public/branding) when nothing is uploaded, so the mail only degrades to
* a text wordmark when there is no absolute base URL to resolve images
* against. The badge and the partner logos always render as one unit — EU
* visibility is contractual and a partner logo never appears on its own.
*/
export async function readEmailBranding(strapi: Core.Strapi): Promise<EmailBranding> {
let siteName = EMPTY_BRANDING.siteName;
try {
const settings = await strapi.documents(SITE_SETTINGS_UID).findFirst({
populate: {
logo: true,
euBadge: true,
consortiumLogos: { populate: { logo: true } },
},
});
if (settings && typeof settings.siteName === 'string') {
siteName = settings.siteName;
}
const base = emailBase(strapi);
if (!base) {
strapi.log.warn(
`${LOG_PREFIX} no absolute base URL (PUBLIC_URL) and not in development; e-mails will use the text wordmark and omit logos.`
);
return { siteName, projectLogo: null, euBadge: null, partners: [] };
}
const mediaUrl = (file: RawMedia): string | null =>
absoluteMediaUrl(base, file?.url);
const bundledUrl = (assetPath: string): string | null =>
absoluteMediaUrl(base, assetPath);
const toPartner = (partner: unknown): EmailPartner => {
const p = partner as RawPartner | undefined;
return {
name: typeof p?.name === 'string' ? p.name : '',
logo: mediaUrl(p?.logo),
};
};
const uploadedPartners: EmailPartner[] = Array.isArray(settings?.consortiumLogos)
? (settings.consortiumLogos as unknown[]).map(toPartner)
: [];
const partners: EmailPartner[] =
uploadedPartners.length > 0
? uploadedPartners
: BUNDLED_BRANDING.partners.map((partner) => ({
name: partner.name,
logo: bundledUrl(partner.logo),
}));
return {
siteName,
projectLogo: mediaUrl(settings?.logo) ?? bundledUrl(BUNDLED_BRANDING.projectLogo),
euBadge: mediaUrl(settings?.euBadge) ?? bundledUrl(BUNDLED_BRANDING.euBadge),
partners,
};
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read site-settings branding, emails will use the text wordmark: ${String(error)}`
);
return EMPTY_BRANDING;
}
}
/* ---------------------------------------------------------------------------
PLACEHOLDERS
------------------------------------------------------------------------ */
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
export type PlaceholderValues = Readonly<Record<string, string>>;
/**
* Fill `{name}` placeholders in a template string. A value of `''` removes
* the placeholder together with the word directly in front of it, so "for
* {call}" (or "على {call}") collapses to nothing instead of leaving a
* dangling connector. Non-empty values are spliced with `split`/`join`, so a
* value containing `$` or backslashes is inserted literally.
*/
export function fillPlaceholders(value: string, vars: PlaceholderValues): string {
let out = value;
for (const name of Object.keys(vars)) {
const replacement = vars[name];
const token = `{${name}}`;
if (replacement === '') {
out = out.replace(new RegExp(`\\S+\\s*\\{${name}\\}`, 'g'), '');
} else {
out = out.split(token).join(replacement);
}
}
return out;
}
+189 -136
View File
@@ -14,13 +14,26 @@
* The Arabic block always comes first and is marked dir="rtl"; the English
* block follows inside the same message. Subjects are bilingual too.
*
* Copy mirrors the approved web copy in apps/web (i18n/messages and
* _events-opportunities/copy.ts); anything invented lives in the seed, not
* here. EU visibility uses the approved wording from the seed sources.
* 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`).
*/
import type { Core } from '@strapi/strapi';
import {
readEmailBranding,
resolveEmailTemplate,
fillPlaceholders,
type EmailBranding,
type PlaceholderValues,
} from './email-templates';
import { mdToHtml, mdToText, escapeHtml } from './markdown';
const EVENT_UID = 'api::event.event';
export type RegistrationState = 'confirmed' | 'waitlisted';
@@ -73,43 +86,9 @@ function formatEventDateTime(
}
/* ---------------------------------------------------------------------------
CONFIRMATION / WAITLIST COPY (approved web copy)
LOCATION LINE
------------------------------------------------------------------------ */
const CONFIRMATION_SUBJECT = {
ar: 'تم تأكيد تسجيلك',
en: 'Registration confirmed',
} as const;
const WAITLIST_SUBJECT = {
ar: 'تمت إضافتك إلى قائمة الانتظار',
en: "You're on the waitlist",
} as const;
const CONFIRMATION_BODY = {
ar: 'شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.',
en: 'Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.',
} as const;
const WAITLIST_BODY = {
ar: 'شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.',
en: 'Thank you — {event} on {date} is fully booked, so we have added you to the waitlist. We will email you if a place becomes available.',
} as const;
/* ---------------------------------------------------------------------------
REMINDER COPY
------------------------------------------------------------------------ */
const REMINDER_SUBJECT_PREFIX = {
ar: 'تذكير',
en: 'Reminder',
} as const;
const REMINDER_BODY = {
ar: 'هذا تذكير بأنك مسجّل في {event} بتاريخ {date}. تبدأ الفعالية في الوقت المُشار إليه أدناه.',
en: 'This is a reminder that you are registered for {event} on {date}. A summary is below.',
} as const;
const LOCATION_LABEL = {
ar: 'الموقع',
en: 'Location',
@@ -120,6 +99,35 @@ const JOIN_LABEL = {
en: 'Join online',
} as const;
/**
* Extra line with the venue or the online link, when the event has one. Each
* locale's line uses that locale's row, falling back to the other row when a
* localized field is empty on one side.
*/
function locationLineFor(event: BilingualEvent): { ar: string; en: string } | undefined {
const format = event.en?.format;
const onlineUrl = event.en?.onlineUrl || event.ar?.onlineUrl;
const venue = {
en: event.en?.venue || event.ar?.venue || '',
ar: event.ar?.venue || event.en?.venue || '',
};
const isOnline = format === 'online' || (format === 'hybrid' && !venue.en);
if (isOnline && onlineUrl) {
return {
ar: `${JOIN_LABEL.ar}: ${onlineUrl}`,
en: `${JOIN_LABEL.en}: ${onlineUrl}`,
};
}
if (venue.en || venue.ar) {
return {
ar: `${LOCATION_LABEL.ar}: ${venue.ar}`,
en: `${LOCATION_LABEL.en}: ${venue.en}`,
};
}
return undefined;
}
/* ---------------------------------------------------------------------------
EU VISIBILITY FOOTER (approved wording)
------------------------------------------------------------------------ */
@@ -136,57 +144,116 @@ const FOOTER = {
const BASE_FONT =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;";
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* The logo chrome shared by every mail: the SHARP-ER project logo at the top
* (the EU badge beside it), and the consortium partner logos under the footer
* text. EU visibility is contractual, so the badge and the partner logos are
* rendered as one unit straight from `site-settings`. Without an absolute
* image URL the header degrades to the site name as a text wordmark.
*/
function wrapParagraphs(text: string): string {
return text
.trim()
.split(/\n\n+/)
.map((paragraph) => `<p>${escapeHtml(paragraph.trim())}</p>`)
const renderBrandHeader = (branding?: EmailBranding): string => {
if (!branding) return '';
const logo = branding.projectLogo
? `<img src="${branding.projectLogo}" alt="${escapeHtml(branding.siteName)}" style="height:40px; width:auto; display:block;" />`
: `<strong style="font-size:18px; color:#222;">${escapeHtml(branding.siteName)}</strong>`;
const badge = branding.euBadge
? `<img src="${branding.euBadge}" alt="${escapeHtml('Funded by the European Union')}" style="height:36px; width:auto; display:block;" />`
: '';
if (!badge) {
return `<div style="margin-bottom:28px; ${BASE_FONT}">${logo}</div>`;
}
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px; ${BASE_FONT}">
<tr>
<td style="padding:0; vertical-align:middle;">${logo}</td>
<td align="right" style="padding:0; vertical-align:middle;">${badge}</td>
</tr>
</table>`;
};
const renderPartnerLogos = (branding?: EmailBranding): string => {
const logos = (branding?.partners ?? []).filter((partner) => partner.logo);
if (logos.length === 0) return '';
const images = logos
.map(
(partner) =>
`<img src="${partner.logo}" alt="${escapeHtml(partner.name)}" title="${escapeHtml(partner.name)}" style="height:28px; width:auto; max-width:160px; margin:0 16px 8px 0; display:inline-block; vertical-align:middle;" />`
)
.join('');
}
return `
<div style="margin-top:24px; padding-top:16px; border-top:1px solid #ddd; ${BASE_FONT}">
${images}
</div>`;
};
const locationHtml = (line?: string): string =>
line
? `<p style="margin:12px 0 0; font-size:14px; color:#444; ${BASE_FONT}">${escapeHtml(line)}</p>`
: '';
/**
* One bilingual message: the Arabic block first (rtl), then English, then the
* EU-visibility footer. Inline styles keep the layout working in mail clients
* that strip <head> style sheets.
* EU-visibility footer and partner logos. Inline styles keep the layout
* working in mail clients that strip <head> style sheets.
*
* The markdown bodies are converted to HTML here, and the `{event}` /
* `{date}` / `{call}` placeholders are filled AFTER conversion — with
* HTML-escaped values in the HTML part and the literal values in the plain
* part — so nothing a title or URL contains can be read as markdown.
*/
export function buildBilingualMail(opts: {
subjectAr: string;
subjectEn: string;
bodyAr: string;
bodyEn: string;
/** Literal {event} / {date} / {call} values, per locale, unescaped. */
vars?: { ar: PlaceholderValues; en: PlaceholderValues };
locationLine?: { ar: string; en: string };
branding?: EmailBranding;
}): { subject: string; text: string; html: string } {
const { subjectAr, subjectEn, bodyAr, bodyEn, locationLine } = opts;
const { subjectAr, subjectEn, bodyAr, bodyEn, vars, locationLine, branding } = opts;
const varsAr = vars?.ar ?? {};
const varsEn = vars?.en ?? {};
const escaped = (values: PlaceholderValues): PlaceholderValues =>
Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
);
const html = `
<div dir="rtl" lang="ar" style="${BASE_FONT} line-height:1.6; color:#222;">
${wrapParagraphs(bodyAr)}
</div>
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" />
<div dir="ltr" lang="en" style="${BASE_FONT} line-height:1.6; color:#222;">
${wrapParagraphs(bodyEn)}
</div>
${renderBrandHeader(branding)}
<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>`;
</p>
${renderPartnerLogos(branding)}`;
const text = [
bodyAr,
fillPlaceholders(mdToText(bodyAr), varsAr),
locationLine?.ar ?? '',
'',
bodyEn,
fillPlaceholders(mdToText(bodyEn), varsEn),
locationLine?.en ?? '',
'',
FOOTER.ar,
@@ -196,7 +263,7 @@ export function buildBilingualMail(opts: {
.join('\n');
return {
subject: `${subjectAr} | ${subjectEn}`,
subject: `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`,
text,
html,
};
@@ -278,35 +345,6 @@ export async function trySendMail(
CONFIRMATION
------------------------------------------------------------------------ */
/**
* Extra line with the venue or the online link, when the event has one. Each
* locale's line uses that locale's row, falling back to the other row when a
* localized field is empty on one side.
*/
function locationLineFor(event: BilingualEvent): { ar: string; en: string } | undefined {
const format = event.en?.format;
const onlineUrl = event.en?.onlineUrl || event.ar?.onlineUrl;
const venue = {
en: event.en?.venue || event.ar?.venue || '',
ar: event.ar?.venue || event.en?.venue || '',
};
const isOnline = format === 'online' || (format === 'hybrid' && !venue.en);
if (isOnline && onlineUrl) {
return {
ar: `${JOIN_LABEL.ar}: ${onlineUrl}`,
en: `${JOIN_LABEL.en}: ${onlineUrl}`,
};
}
if (venue.en || venue.ar) {
return {
ar: `${LOCATION_LABEL.ar}: ${venue.ar}`,
en: `${LOCATION_LABEL.en}: ${venue.en}`,
};
}
return undefined;
}
/**
* Build and send the confirmation (or waitlist) e-mail for a registration.
* Best-effort: logs failures, never throws into the request path.
@@ -328,7 +366,13 @@ export async function sendRegistrationConfirmation({
eventReference: EventReference;
}): Promise<boolean> {
try {
const event = await fetchBilingualEvent(strapi, eventReference);
const isWaitlist = state === 'waitlisted';
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'),
readEmailBranding(strapi),
]);
if (!event.en && !event.ar) {
strapi.log.warn(
@@ -353,20 +397,17 @@ export async function sendRegistrationConfirmation({
timeZone,
);
const isWaitlist = state === 'waitlisted';
const subjectAr = isWaitlist ? WAITLIST_SUBJECT.ar : CONFIRMATION_SUBJECT.ar;
const subjectEn = isWaitlist ? WAITLIST_SUBJECT.en : CONFIRMATION_SUBJECT.en;
const bodyAr = isWaitlist ? WAITLIST_BODY.ar : CONFIRMATION_BODY.ar;
const bodyEn = isWaitlist ? WAITLIST_BODY.en : CONFIRMATION_BODY.en;
const mail = buildBilingualMail({
subjectAr,
subjectEn,
bodyAr: bodyAr
.replace(/\{event\}/g, titleAr)
.replace(/\{date\}/g, dateAr),
bodyEn: bodyEn.replace(/\{event\}/g, titleEn).replace(/\{date\}/g, dateEn),
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { event: titleAr, date: dateAr },
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
});
return trySendMail(strapi, { to, ...mail });
@@ -390,36 +431,48 @@ export async function sendEventReminderMail({
to: string;
eventReference: EventReference;
}): Promise<boolean> {
const event = await fetchBilingualEvent(strapi, eventReference);
try {
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, 'event-reminder'),
readEmailBranding(strapi),
]);
if (!event.en && !event.ar) {
strapi.log.warn(
'[event-mail] reminder skipped: no published event for the registration.',
if (!event.en && !event.ar) {
strapi.log.warn(
'[event-mail] reminder skipped: no published event for the registration.',
);
return false;
}
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
const titleEn = event.en?.title || event.ar?.title || '';
const titleAr = event.ar?.title || event.en?.title || '';
const dateEn = formatEventDateTime(event.en?.startDateTime, 'en', timeZone);
const dateAr = formatEventDateTime(
event.ar?.startDateTime ?? event.en?.startDateTime,
'ar',
timeZone,
);
const mail = buildBilingualMail({
subjectAr: template.ar.subject,
subjectEn: template.en.subject,
bodyAr: template.ar.body,
bodyEn: template.en.body,
vars: {
ar: { event: titleAr, date: dateAr },
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
});
return trySendMail(strapi, { to, ...mail });
} catch (error) {
strapi.log.error(`[event-mail] reminder failed: ${String(error)}`);
return false;
}
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
const titleEn = event.en?.title || event.ar?.title || '';
const titleAr = event.ar?.title || event.en?.title || '';
const dateEn = formatEventDateTime(event.en?.startDateTime, 'en', timeZone);
const dateAr = formatEventDateTime(
event.ar?.startDateTime ?? event.en?.startDateTime,
'ar',
timeZone,
);
const mail = buildBilingualMail({
subjectAr: `${REMINDER_SUBJECT_PREFIX.ar}: ${titleAr}`,
subjectEn: `${REMINDER_SUBJECT_PREFIX.en}: ${titleEn}`,
bodyAr: REMINDER_BODY.ar
.replace(/\{event\}/g, titleAr)
.replace(/\{date\}/g, dateAr),
bodyEn: REMINDER_BODY.en.replace(/\{event\}/g, titleEn).replace(/\{date\}/g, dateEn),
locationLine: locationLineFor(event),
});
return trySendMail(strapi, { to, ...mail });
}
+254
View File
@@ -0,0 +1,254 @@
/**
* markdown — a tiny markdown-to-HTML renderer for email bodies.
*
* Email templates are markdown in the CMS (components/email/template.json).
* This converts the subset the editors actually use — headings, paragraphs,
* bullet and numbered lists, blockquotes, plus inline bold / italic / code /
* links — into the inline-styled HTML email clients expect.
*
* There is deliberately NO generic HTML passthrough and no markdown
* dependency: the source is HTML-escaped FIRST, so anything that is not
* recognised converts to visible text rather than markup (the same position
* the web app's rich-text renderer takes). Links are the one exception — an
* `href` is emitted only when its scheme is http, https or mailto; anything
* else renders as its label, as plain text.
*
* `mdToText` renders the same blocks as plain text, so the message's plain
* part stays in step with the HTML part.
*/
const ESCAPE = /[&<>"']/g;
const ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/** Escape text so the rest of this module can treat it as inert data. */
export function escapeHtml(value: string): string {
return value.replace(ESCAPE, (ch) => ESCAPE_MAP[ch]!);
}
/* ---------------------------------------------------------------------------
INLINE
------------------------------------------------------------------------ */
/** `**bold**`, `*italic*`, `` `code` ``, `[label](/href)`. */
const INLINE_PATTERN =
/(\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g;
const LINK_PATTERN = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/;
/** Schemes an email may link to. Everything else renders as its label. */
const SAFE_LINK = /^(?:https?:|mailto:)/i;
/**
* Render the inline markup in one (already HTML-escaped) line into an HTML
* fragment. `href` values are not re-escaped here because the source was
* escaped up-front.
*/
function inlineToHtml(text: string): string {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== '' && part !== undefined)
.map((part) => {
if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
return `<strong>${part.slice(2, -2)}</strong>`;
}
if (part.startsWith('__') && part.endsWith('__') && part.length > 4) {
return `<strong>${part.slice(2, -2)}</strong>`;
}
if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
return `<em>${part.slice(1, -1)}</em>`;
}
if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
return `<code style="font-family:Menlo,Consolas,monospace; font-size:0.9em; background:#f4f4f4; padding:1px 4px; border-radius:3px;">${part.slice(1, -1)}</code>`;
}
const link = LINK_PATTERN.exec(part);
if (link) {
const [, label, target] = link;
if (!SAFE_LINK.test(target)) return label;
return `<a href="${target}" style="color:#008764; text-decoration:underline;">${label}</a>`;
}
return part;
})
.join('');
}
/** Strip inline markup instead of turning it into tags (for the text part). */
function inlineToText(text: string): string {
return text
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/\*([^*\n]+)\*/g, '$1')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '$1');
}
/* ---------------------------------------------------------------------------
BLOCKS
------------------------------------------------------------------------ */
type Block =
| { kind: 'heading'; level: 2 | 3 | 4; text: string }
| { kind: 'paragraph'; text: string }
| { kind: 'quote'; text: string }
| { kind: 'list'; ordered: boolean; items: string[] };
const HEADING = /^(#{1,6})\s+(.*)$/;
const BULLET = /^[-*+]\s+(.*)$/;
const NUMBERED = /^\d+[.)]\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
/**
* Group the source's lines into blocks, mirroring the web app's
* `parseMarkdown` (apps/web/app/routes/_page-kit/rich-text.tsx).
*/
export function parseMarkdown(source: string): Block[] {
const blocks: Block[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
let listItems: string[] = [];
let listOrdered = false;
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') });
paragraph = [];
}
};
const flushQuote = () => {
if (quote.length > 0) {
blocks.push({ kind: 'quote', text: quote.join(' ') });
quote = [];
}
};
const flushList = () => {
if (listItems.length > 0) {
blocks.push({ kind: 'list', ordered: listOrdered, items: listItems });
listItems = [];
}
};
const flushAll = () => {
flushParagraph();
flushQuote();
flushList();
};
for (const rawLine of source.replace(/\r\n?/g, '\n').split('\n')) {
const line = rawLine.trim();
if (line === '') {
flushAll();
continue;
}
const heading = HEADING.exec(line);
if (heading) {
flushAll();
// The mail owns no h1, so CMS headings start at h2 and never pass h4.
const level = Math.min(Math.max(heading[1]!.length + 1, 2), 4) as 2 | 3 | 4;
blocks.push({ kind: 'heading', level, text: heading[2]! });
continue;
}
const quoted = QUOTE.exec(line);
if (quoted) {
flushParagraph();
flushList();
quote.push(quoted[1]!);
continue;
}
const bullet = BULLET.exec(line);
const numbered = NUMBERED.exec(line);
if (bullet ?? numbered) {
flushParagraph();
flushQuote();
const ordered = numbered !== null;
if (listItems.length > 0 && ordered !== listOrdered) flushList();
listOrdered = ordered;
listItems.push((numbered?.[1] ?? bullet?.[1])!);
continue;
}
flushQuote();
flushList();
paragraph.push(line);
}
flushAll();
return blocks;
}
/* ---------------------------------------------------------------------------
RENDERERS
------------------------------------------------------------------------ */
/**
* Convert an email template's markdown body to HTML. The input is escaped
* before parsing, so unrecognised syntax (or any raw HTML) surfaces as text.
*/
export function mdToHtml(source: string): string {
const blocks = parseMarkdown(source);
return blocks
.map((block) => {
switch (block.kind) {
case 'heading': {
const tag = `h${block.level}`;
const size =
block.level === 2 ? '20px' : block.level === 3 ? '17px' : '15px';
return `<${tag} style="margin:0 0 8px; font-size:${size}; line-height:1.35; color:#222;">${inlineToHtml(block.text)}</${tag}>`;
}
case 'quote': {
const text = inlineToHtml(block.text);
return `<blockquote style="margin:0 0 12px; padding:0 0 0 12px; border-left:2px solid #008764; color:#444;">${text}</blockquote>`;
}
case 'list': {
const tag = block.ordered ? 'ol' : 'ul';
const listStyle = block.ordered ? 'decimal' : 'disc';
const items = block.items
.map((item) => `<li style="margin:0 0 4px;">${inlineToHtml(item)}</li>`)
.join('');
return `<${tag} style="margin:0 0 12px; padding:0 0 0 18px; list-style:${listStyle};">${items}</${tag}>`;
}
default:
return `<p style="margin:0 0 12px; line-height:1.6;">${inlineToHtml(block.text)}</p>`;
}
})
.join('');
}
/**
* Convert an email template's markdown body to plain text, for the message's
* `text` part. Paragraphs are separated by one blank line, list items by "- "
* / "N. ", blockquotes just drop their marker.
*/
export function mdToText(source: string): string {
const blocks = parseMarkdown(source);
return blocks
.map((block) => {
switch (block.kind) {
case 'heading':
return block.text;
case 'quote':
return inlineToText(block.text);
case 'list':
return block.items
.map((item, index) =>
block.ordered
? `${index + 1}. ${inlineToText(item)}`
: `- ${inlineToText(item)}`
)
.join('\n');
default:
return inlineToText(block.text);
}
})
.join('\n\n');
}