feat: email templates in CMS
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
/** 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');
|
||||
}
|
||||
Reference in New Issue
Block a user