From 60dd92e4b07c57f022adb5acc6f78147fc5baf7c Mon Sep 17 00:00:00 2001 From: Hisham Aburaqibah Date: Thu, 24 Sep 2026 14:20:24 +0200 Subject: [PATCH] feat: email templates in CMS --- apps/cms/.env.example | 10 +- apps/cms/public/branding/eu-emblem.png | Bin 0 -> 6699 bytes apps/cms/public/branding/partner-acted.png | Bin 0 -> 17529 bytes apps/cms/public/branding/partner-sbe.png | Bin 0 -> 11221 bytes apps/cms/public/branding/partner-uec.png | Bin 0 -> 9423 bytes .../cms/public/branding/sharp-er-wordmark.png | Bin 0 -> 5229 bytes .../content-types/email-settings/schema.json | 31 ++ .../middlewares/sanitize-submission.ts | 25 +- .../services/event-registration.ts | 65 +--- apps/cms/src/bootstrap/email-settings.ts | 58 ++++ apps/cms/src/bootstrap/index.ts | 3 + apps/cms/src/components/email/template.json | 35 ++ apps/cms/src/lib/application-mail.ts | 61 ++-- apps/cms/src/lib/email-template-defaults.ts | 102 ++++++ apps/cms/src/lib/email-templates.ts | 295 ++++++++++++++++ apps/cms/src/lib/event-mail.ts | 325 ++++++++++-------- apps/cms/src/lib/markdown.ts | 254 ++++++++++++++ apps/cms/types/generated/components.d.ts | 28 ++ apps/cms/types/generated/contentTypes.d.ts | 40 +++ 19 files changed, 1100 insertions(+), 232 deletions(-) create mode 100644 apps/cms/public/branding/eu-emblem.png create mode 100644 apps/cms/public/branding/partner-acted.png create mode 100644 apps/cms/public/branding/partner-sbe.png create mode 100644 apps/cms/public/branding/partner-uec.png create mode 100644 apps/cms/public/branding/sharp-er-wordmark.png create mode 100644 apps/cms/src/api/email-settings/content-types/email-settings/schema.json create mode 100644 apps/cms/src/bootstrap/email-settings.ts create mode 100644 apps/cms/src/components/email/template.json create mode 100644 apps/cms/src/lib/email-template-defaults.ts create mode 100644 apps/cms/src/lib/email-templates.ts create mode 100644 apps/cms/src/lib/markdown.ts diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 218ff5f..4a91378 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -19,7 +19,7 @@ PORT=1337 # Public URL of the API, used to build absolute media/admin URLs. # Leave commented out in local development; set it behind a proxy or a custom # domain, e.g. PUBLIC_URL=https://cms.sharper.ly -# PUBLIC_URL= +PUBLIC_URL=https://cms.sharper.ly # true when Strapi sits behind nginx / a load balancer, so X-Forwarded-* headers # are trusted for the client IP and protocol. IS_PROXIED=false @@ -152,7 +152,13 @@ SMTP_PASSWORD=your-password DEFAULT_FROM=no-reply@yourdomain.com DEFAULT_REPLY_TO=support@yourdomain.com # The values above are also the from/reply-to for the registration -# confirmation and event-reminder e-mails (src/lib/event-mail.ts). +# confirmation, event-reminder and application-acknowledgement e-mails +# (src/lib/event-mail.ts, src/lib/application-mail.ts). +# +# The subject and body of those e-mails are editable in the CMS (the +# email-settings single type); the SHARP-ER / EU / partner logo header needs +# PUBLIC_URL (above) to build absolute image URLs - without it the mail falls +# back to a text wordmark. # Production only - switching the upload provider to S3. Uncomment these after # adding @strapi/provider-upload-aws-s3 and editing the upload block in diff --git a/apps/cms/public/branding/eu-emblem.png b/apps/cms/public/branding/eu-emblem.png new file mode 100644 index 0000000000000000000000000000000000000000..367afe79967524ee6c8c1ff0fd20ecbbe9e550fb GIT binary patch literal 6699 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 B2{r%# literal 0 HcmV?d00001 diff --git a/apps/cms/public/branding/partner-acted.png b/apps/cms/public/branding/partner-acted.png new file mode 100644 index 0000000000000000000000000000000000000000..f4af3b21c70ded073b583f290443f6f7a6d8edd4 GIT binary patch literal 17529 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 N002ovPDHLkV1gph7VQ84 literal 0 HcmV?d00001 diff --git a/apps/cms/public/branding/partner-sbe.png b/apps/cms/public/branding/partner-sbe.png new file mode 100644 index 0000000000000000000000000000000000000000..45bc7e922e68581f2b9808622701d91024c8d692 GIT binary patch literal 11221 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
Y3v4>gxWq*E<#jvb^)+Q$17n9kmt>~Y)i?r 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 literal 0 HcmV?d00001 diff --git a/apps/cms/public/branding/partner-uec.png b/apps/cms/public/branding/partner-uec.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e2342f53515f0832bc29b5367a4dce22ea528b GIT binary patch literal 9423 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 0) { + void sendRegistrationConfirmation({ + strapi, + to: data.email, + state: data.registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed', + eventReference: readEventReference(data.event), + }); + } else { + strapi.log.warn( + 'no email on the registration request; confirmation e-mail skipped.', + ); + } }; export default sanitizeSubmission; diff --git a/apps/cms/src/api/event-registration/services/event-registration.ts b/apps/cms/src/api/event-registration/services/event-registration.ts index 5278e1c..fd8dafd 100644 --- a/apps/cms/src/api/event-registration/services/event-registration.ts +++ b/apps/cms/src/api/event-registration/services/event-registration.ts @@ -1,64 +1,15 @@ /** * event-registration service * - * `create` is overridden to fire the confirmation e-mail *after* the row is - * written. The send is deliberately not awaited: a slow or failing SMTP call - * must never turn the registration POST into a 502. The mail helper swallows - * and logs its own errors, and the state derivation stays in the - * sanitize-submission middleware - nothing here recomputes confirmed/waitlisted. + * Plain default `create` service. The registration row is written inside the + * transaction opened by the sanitize-submission middleware, and that middleware + * dispatches the confirmation e-mail *after* the transaction has committed. + * It must not be dispatched from here: a send fired inside the request + * transaction would bind the mail helper's own reads (event, template, + * branding) to a transaction that is about to be committed, which knex + * rejects with "Transaction query already complete". */ import { factories } from '@strapi/strapi'; -import { sendRegistrationConfirmation } from '../../../lib/event-mail'; - -const REGISTRATION_UID = 'api::event-registration.event-registration'; -const LOG_PREFIX = '[event-registration]'; - -/** - * The `event` value a client may have sent is a documentId (what the REST API - * uses for relations), or a legacy numeric id. Mirrors the middleware's reader - * so the confirmation mail points at the event the registrant chose. - */ -const readEventReference = (value: unknown): { documentId?: string; id?: number } | undefined => { - if (typeof value === 'string' && value.length > 0) { - return { documentId: value }; - } - - if (typeof value === 'number' && Number.isInteger(value)) { - return { id: value }; - } - - return undefined; -}; - -const readCreatedResult = (result: unknown): { email?: unknown; registrationState?: unknown } => - (typeof result === 'object' && result !== null ? result : {}) as { - email?: unknown; - registrationState?: unknown; - }; - -export default factories.createCoreService(REGISTRATION_UID, ({ strapi }) => ({ - async create(params: { data?: { event?: unknown } } & Record) { - const result = await super.create(params); - - const { email, registrationState } = readCreatedResult(result); - - if (typeof email === 'string' && email.length > 0) { - const eventReference = readEventReference(params.data?.event); - - void sendRegistrationConfirmation({ - strapi, - to: email, - state: registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed', - eventReference, - }); - } else { - strapi.log.warn( - `${LOG_PREFIX} no email on the created registration; confirmation e-mail skipped.`, - ); - } - - return result; - }, -})); \ No newline at end of file +export default factories.createCoreService('api::event-registration.event-registration'); \ No newline at end of file diff --git a/apps/cms/src/bootstrap/email-settings.ts b/apps/cms/src/bootstrap/email-settings.ts new file mode 100644 index 0000000..3602165 --- /dev/null +++ b/apps/cms/src/bootstrap/email-settings.ts @@ -0,0 +1,58 @@ +import type { Core } from '@strapi/strapi'; + +import { + DEFAULT_EMAIL_TEMPLATES, + EMAIL_TEMPLATE_KEYS, + type EmailTemplateKey, +} from '../lib/email-template-defaults'; +import { LOG_PREFIX } from './constants'; + +const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings'; + +/** + * The `email.template` component payload for one locale of one template. + * `enabled` stays on so the shipped wording is the live configuration, keeping + * code defaults and CMS state in agreement on a fresh database. + */ +const templateRow = (key: EmailTemplateKey, locale: 'en' | 'ar') => ({ + key, + enabled: true, + subject: DEFAULT_EMAIL_TEMPLATES[key][locale].subject, + body: DEFAULT_EMAIL_TEMPLATES[key][locale].body, +}); + +/** + * Create the `email-settings` single type if the database does not have one + * yet, so the four email templates exist and are editable in the admin panel + * after the very first boot — carrying the shipped default copy. Never + * overwrites an existing row, so editor customisation survives every restart. + */ +export const ensureEmailSettings = async (strapi: Core.Strapi): Promise => { + const existing = await strapi + .documents(EMAIL_SETTINGS_UID) + .findFirst({}); + + if (existing) { + return; + } + + const templatesFor = (locale: 'en' | 'ar') => + EMAIL_TEMPLATE_KEYS.map((key) => templateRow(key, locale)); + + /* Arabic first — it is the default locale and the one a single type is + created on — then the English translation is written onto the same + document, which is how Strapi 5 models one document in two locales. */ + const created = await strapi.documents(EMAIL_SETTINGS_UID).create({ + locale: 'ar', + data: { templates: templatesFor('ar') }, + }); + await strapi.documents(EMAIL_SETTINGS_UID).update({ + documentId: created.documentId, + locale: 'en', + data: { templates: templatesFor('en') }, + }); + + strapi.log.info( + `${LOG_PREFIX} created email-settings single type with the four default email templates.` + ); +}; \ No newline at end of file diff --git a/apps/cms/src/bootstrap/index.ts b/apps/cms/src/bootstrap/index.ts index 205b231..cc8f827 100644 --- a/apps/cms/src/bootstrap/index.ts +++ b/apps/cms/src/bootstrap/index.ts @@ -2,11 +2,13 @@ import type { Core } from '@strapi/strapi'; import { revokePublicAuthActions } from './auth-permissions'; import { LOG_PREFIX } from './constants'; +import { ensureEmailSettings } from './email-settings'; import { ensureLocales } from './locales'; import { ensurePublicPermissions } from './public-permissions'; import { ensureReminderSettings } from './reminder-settings'; export { revokePublicAuthActions } from './auth-permissions'; +export { ensureEmailSettings } from './email-settings'; export { ensureLocales } from './locales'; export { ensurePublicPermissions } from './public-permissions'; export { ensureReminderSettings } from './reminder-settings'; @@ -29,6 +31,7 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise await ensurePublicPermissions(strapi); await revokePublicAuthActions(strapi); await ensureReminderSettings(strapi); + await ensureEmailSettings(strapi); strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`); }; diff --git a/apps/cms/src/components/email/template.json b/apps/cms/src/components/email/template.json new file mode 100644 index 0000000..59b0711 --- /dev/null +++ b/apps/cms/src/components/email/template.json @@ -0,0 +1,35 @@ +{ + "collectionName": "components_email_templates", + "info": { + "displayName": "Email Template", + "icon": "envelop", + "description": "One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so \"for {call}\" (and \"على {call}\") reads cleanly when there is no call." + }, + "options": {}, + "attributes": { + "key": { + "type": "enumeration", + "enum": [ + "confirmation", + "waitlist", + "event-reminder", + "application-acknowledgement" + ], + "required": true + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Off reverts this email to the shipped default copy." + }, + "subject": { + "type": "string", + "required": true, + "maxLength": 250 + }, + "body": { + "type": "richtext", + "required": true + } + } +} \ No newline at end of file diff --git a/apps/cms/src/lib/application-mail.ts b/apps/cms/src/lib/application-mail.ts index e494e93..76c40c2 100644 --- a/apps/cms/src/lib/application-mail.ts +++ b/apps/cms/src/lib/application-mail.ts @@ -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 { 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 }); diff --git a/apps/cms/src/lib/email-template-defaults.ts b/apps/cms/src/lib/email-template-defaults.ts new file mode 100644 index 0000000..79ce2ae --- /dev/null +++ b/apps/cms/src/lib/email-template-defaults.ts @@ -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 +> = { + 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, + }, + }, +}; \ No newline at end of file diff --git a/apps/cms/src/lib/email-templates.ts b/apps/cms/src/lib/email-templates.ts new file mode 100644 index 0000000..4b95dfb --- /dev/null +++ b/apps/cms/src/lib/email-templates.ts @@ -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 { + 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 { + 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>; + +/** + * 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; +} \ No newline at end of file diff --git a/apps/cms/src/lib/event-mail.ts b/apps/cms/src/lib/event-mail.ts index 44c96f0..08d34ae 100644 --- a/apps/cms/src/lib/event-mail.ts +++ b/apps/cms/src/lib/event-mail.ts @@ -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, '''); -} +/** + * 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) => `

${escapeHtml(paragraph.trim())}

`) +const renderBrandHeader = (branding?: EmailBranding): string => { + if (!branding) return ''; + + const logo = branding.projectLogo + ? `${escapeHtml(branding.siteName)}` + : `${escapeHtml(branding.siteName)}`; + + const badge = branding.euBadge + ? `${escapeHtml('Funded by the European Union')}` + : ''; + + if (!badge) { + return `
${logo}
`; + } + + return ` + + + + + +
${logo}${badge}
`; +}; + +const renderPartnerLogos = (branding?: EmailBranding): string => { + const logos = (branding?.partners ?? []).filter((partner) => partner.logo); + if (logos.length === 0) return ''; + + const images = logos + .map( + (partner) => + `${escapeHtml(partner.name)}` + ) .join(''); -} + + return ` +
+ ${images} +
`; +}; + +const locationHtml = (line?: string): string => + line + ? `

${escapeHtml(line)}

` + : ''; /** * 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 style sheets. + * EU-visibility footer and partner logos. Inline styles keep the layout + * working in mail clients that strip 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 = ` -
- ${wrapParagraphs(bodyAr)} -
-
-
- ${wrapParagraphs(bodyEn)} -
+ ${renderBrandHeader(branding)} +
+
+ ${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))} + ${locationHtml(locationLine?.ar)} +
+
+
+ ${fillPlaceholders(mdToHtml(bodyEn), escaped(varsEn))} + ${locationHtml(locationLine?.en)} +
+

${FOOTER.ar}

${FOOTER.en} -

`; +

+ ${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 { 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 { - 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 }); } \ No newline at end of file diff --git a/apps/cms/src/lib/markdown.ts b/apps/cms/src/lib/markdown.ts new file mode 100644 index 0000000..827a29d --- /dev/null +++ b/apps/cms/src/lib/markdown.ts @@ -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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** 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 `${part.slice(2, -2)}`; + } + if (part.startsWith('__') && part.endsWith('__') && part.length > 4) { + return `${part.slice(2, -2)}`; + } + if (part.startsWith('*') && part.endsWith('*') && part.length > 2) { + return `${part.slice(1, -1)}`; + } + if (part.startsWith('`') && part.endsWith('`') && part.length > 2) { + return `${part.slice(1, -1)}`; + } + + const link = LINK_PATTERN.exec(part); + if (link) { + const [, label, target] = link; + if (!SAFE_LINK.test(target)) return label; + return `${label}`; + } + + 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)}`; + } + case 'quote': { + const text = inlineToHtml(block.text); + return `
${text}
`; + } + case 'list': { + const tag = block.ordered ? 'ol' : 'ul'; + const listStyle = block.ordered ? 'decimal' : 'disc'; + const items = block.items + .map((item) => `
  • ${inlineToHtml(item)}
  • `) + .join(''); + return `<${tag} style="margin:0 0 12px; padding:0 0 0 18px; list-style:${listStyle};">${items}`; + } + default: + return `

    ${inlineToHtml(block.text)}

    `; + } + }) + .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'); +} \ No newline at end of file diff --git a/apps/cms/types/generated/components.d.ts b/apps/cms/types/generated/components.d.ts index 25c539a..64cb223 100644 --- a/apps/cms/types/generated/components.d.ts +++ b/apps/cms/types/generated/components.d.ts @@ -110,6 +110,33 @@ export interface BlocksVideoEmbed extends Struct.ComponentSchema { }; } +export interface EmailTemplate extends Struct.ComponentSchema { + collectionName: 'components_email_templates'; + info: { + description: 'One outbound email template: a subject plus a markdown body. The body is converted to HTML at send time. Placeholders: {event}, {date} and {call}. A placeholder whose value is missing collapses together with the word before it, so "for {call}" (and "\u0639\u0644\u0649 {call}") reads cleanly when there is no call.'; + displayName: 'Email Template'; + icon: 'envelop'; + }; + attributes: { + body: Schema.Attribute.RichText & Schema.Attribute.Required; + enabled: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; + key: Schema.Attribute.Enumeration< + [ + 'confirmation', + 'waitlist', + 'event-reminder', + 'application-acknowledgement', + ] + > & + Schema.Attribute.Required; + subject: Schema.Attribute.String & + Schema.Attribute.Required & + Schema.Attribute.SetMinMaxLength<{ + maxLength: 250; + }>; + }; +} + export interface NavigationFooterColumn extends Struct.ComponentSchema { collectionName: 'components_navigation_footer_columns'; info: { @@ -323,6 +350,7 @@ declare module '@strapi/strapi' { 'blocks.quote': BlocksQuote; 'blocks.rich-text': BlocksRichText; 'blocks.video-embed': BlocksVideoEmbed; + 'email.template': EmailTemplate; 'navigation.footer-column': NavigationFooterColumn; 'navigation.nav-group': NavigationNavGroup; 'page.band-intro': PageBandIntro; diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index e492b29..e3401cd 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -852,6 +852,45 @@ export interface ApiContactSubmissionContactSubmission }; } +export interface ApiEmailSettingsEmailSettings extends Struct.SingleTypeSchema { + collectionName: 'email_settings'; + info: { + description: 'The outbound email templates (registration confirmation, waitlist, reminder, application acknowledgement). Markdown bodies become HTML at send time. The values seeded here on first boot are also the defaults the mails fall back to when the row is missing. Read by the mail jobs only; not exposed over the public API.'; + displayName: 'Email Settings'; + pluralName: 'email-settings-list'; + singularName: 'email-settings'; + }; + options: { + draftAndPublish: false; + }; + pluginOptions: { + i18n: { + localized: true; + }; + }; + attributes: { + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + locale: Schema.Attribute.String; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::email-settings.email-settings' + >; + publishedAt: Schema.Attribute.DateTime; + templates: Schema.Attribute.Component<'email.template', true> & + Schema.Attribute.Required & + Schema.Attribute.SetPluginOptions<{ + i18n: { + localized: true; + }; + }>; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + export interface ApiEventRegistrationEventRegistration extends Struct.CollectionTypeSchema { collectionName: 'event_registrations'; @@ -2744,6 +2783,7 @@ declare module '@strapi/strapi' { 'api::application.application': ApiApplicationApplication; 'api::contact-page.contact-page': ApiContactPageContactPage; 'api::contact-submission.contact-submission': ApiContactSubmissionContactSubmission; + 'api::email-settings.email-settings': ApiEmailSettingsEmailSettings; 'api::event-registration.event-registration': ApiEventRegistrationEventRegistration; 'api::event.event': ApiEventEvent; 'api::faq.faq': ApiFaqFaq;