From dcf4c851285a22724d57610c32b09bfbf8a9e723 Mon Sep 17 00:00:00 2001 From: Hisham Aburaqibah Date: Mon, 28 Sep 2026 12:20:21 +0200 Subject: [PATCH] remove mail templates from CMS --- .../middlewares/sanitize-submission.ts | 2 +- .../services/event-registration.ts | 4 +- apps/cms/src/lib/application-mail.ts | 9 +- apps/cms/src/lib/email-templates.ts | 244 +----------------- apps/cms/src/lib/event-mail.ts | 67 ++--- 5 files changed, 43 insertions(+), 283 deletions(-) diff --git a/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts b/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts index 980f91d..5669830 100644 --- a/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts +++ b/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts @@ -202,7 +202,7 @@ const sanitizeSubmission: Core.MiddlewareFactory = }); // Send the confirmation only after the transaction above has committed: - // the mail helper reads the event, template and branding back from the + // the mail helper reads the event and template back from the // database, and running those reads inside the transaction would bind them // to a transaction that is about to be committed, which knex rejects // ("Transaction query already complete"). Dispatched without awaiting so a 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 fd8dafd..383390b 100644 --- a/apps/cms/src/api/event-registration/services/event-registration.ts +++ b/apps/cms/src/api/event-registration/services/event-registration.ts @@ -5,8 +5,8 @@ * 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 + * transaction would bind the mail helper's own reads (event and template) + * to a transaction that is about to be committed, which knex * rejects with "Transaction query already complete". */ diff --git a/apps/cms/src/lib/application-mail.ts b/apps/cms/src/lib/application-mail.ts index 76c40c2..84817bc 100644 --- a/apps/cms/src/lib/application-mail.ts +++ b/apps/cms/src/lib/application-mail.ts @@ -23,10 +23,7 @@ import type { Core } from '@strapi/strapi'; -import { - readEmailBranding, - resolveEmailTemplate, -} from './email-templates'; +import { resolveEmailTemplate } from './email-templates'; import { buildBilingualMail, trySendMail } from './event-mail'; const OPPORTUNITY_UID = 'api::opportunity.opportunity'; @@ -88,10 +85,9 @@ export async function sendApplicationAcknowledgement({ opportunityReference: OpportunityReference; }): Promise { try { - const [opportunity, template, branding] = await Promise.all([ + const [opportunity, template] = await Promise.all([ fetchBilingualOpportunity(strapi, opportunityReference), resolveEmailTemplate(strapi, 'application-acknowledgement'), - readEmailBranding(strapi), ]); const callEn = opportunity.en?.title || undefined; @@ -106,7 +102,6 @@ export async function sendApplicationAcknowledgement({ ar: { call: callAr ?? '' }, en: { call: callEn ?? '' }, }, - branding, }); const ok = await trySendMail(strapi, { to, ...mail }); diff --git a/apps/cms/src/lib/email-templates.ts b/apps/cms/src/lib/email-templates.ts index 4b95dfb..ef8bce3 100644 --- a/apps/cms/src/lib/email-templates.ts +++ b/apps/cms/src/lib/email-templates.ts @@ -1,25 +1,13 @@ /** - * email-templates — resolve the CMS-configured copy and branding the outbound - * mails are built from. + * email-templates — resolve hardcoded copy and shared placeholders for the + * outbound mails. * - * 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: + * The subjects and markdown bodies live in email-template-defaults.ts. This + * resolver returns those values directly; the CMS has no runtime copy source. * * resolveEmailTemplate the subject/body pair for a key, per locale, each * locale falling back to its own code default when * the row is missing, switched off or unreadable. - * 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 @@ -35,235 +23,25 @@ import { 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. */ +/** The hardcoded per-locale subject/body for one template key. */ export type ResolvedEmailTemplate = { ar: LocalisedEmailTemplate; en: LocalisedEmailTemplate; }; /** - * The subject and body for one template key, per locale. 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. + * The subject and body for one template key, per locale. The retained Strapi + * argument keeps callers stable while all senders move to the static source. */ -export async function resolveEmailTemplate( - strapi: Core.Strapi, +export 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; - } +): ResolvedEmailTemplate { + return DEFAULT_EMAIL_TEMPLATES[key]; } /* --------------------------------------------------------------------------- diff --git a/apps/cms/src/lib/event-mail.ts b/apps/cms/src/lib/event-mail.ts index 6025b20..791630f 100644 --- a/apps/cms/src/lib/event-mail.ts +++ b/apps/cms/src/lib/event-mail.ts @@ -27,10 +27,8 @@ import type { Core } from '@strapi/strapi'; import { buildEventCalendar } from './event-calendar'; import { - readEmailBranding, resolveEmailTemplate, fillPlaceholders, - type EmailBranding, type PlaceholderValues, } from './email-templates'; import { mdToHtml, mdToText, escapeHtml } from './markdown'; @@ -316,46 +314,41 @@ const FOOTER = { const BASE_FONT = "font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;"; +const EMAIL_LOGOS = { + project: 'https://cms.sharper.ly/branding/sharp-er-wordmark.png', + eu: 'https://cms.sharper.ly/branding/eu-emblem.png', + partners: [ + { name: 'Acted', url: 'https://cms.sharper.ly/branding/partner-acted.png' }, + { + name: 'United Efforts Consultancy (UEC)', + url: 'https://cms.sharper.ly/branding/partner-uec.png', + }, + { + name: 'Stichting Bureau Ensemble (SBE)', + url: 'https://cms.sharper.ly/branding/partner-sbe.png', + }, + ], +} as const; + /** * 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. + * linked to the public assets on the project website. */ - -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 ` +const renderBrandHeader = (): string => ` - - + +
${logo}${badge}SHARP-ER${escapeHtml('Funded by the European Union')}
`; -}; -const renderPartnerLogos = (branding?: EmailBranding): string => { - const logos = (branding?.partners ?? []).filter((partner) => partner.logo); - if (logos.length === 0) return ''; - - const images = logos +const renderPartnerLogos = (): string => { + const images = EMAIL_LOGOS.partners .map( (partner) => - `${escapeHtml(partner.name)}` + `${escapeHtml(partner.name)}` ) .join(''); @@ -388,7 +381,6 @@ export function buildBilingualMail(opts: { /** Literal {event} / {date} / {call} values, per locale, unescaped. */ vars?: { ar: PlaceholderValues; en: PlaceholderValues }; locationLine?: { ar: string; en: string }; - branding?: EmailBranding; jsonLd?: JsonLd; }): { subject: string; text: string; html: string } { const { @@ -398,7 +390,6 @@ export function buildBilingualMail(opts: { bodyEn, vars, locationLine, - branding, jsonLd, } = opts; @@ -412,7 +403,7 @@ export function buildBilingualMail(opts: { const html = ` ${renderJsonLd(jsonLd)} - ${renderBrandHeader(branding)} + ${renderBrandHeader()}
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))} @@ -430,7 +421,7 @@ export function buildBilingualMail(opts: {

${FOOTER.en}

- ${renderPartnerLogos(branding)}`; + ${renderPartnerLogos()}`; const text = [ fillPlaceholders(mdToText(bodyAr), varsAr), @@ -562,10 +553,9 @@ export async function sendRegistrationConfirmation({ try { const isWaitlist = state === 'waitlisted'; - const [event, template, branding] = await Promise.all([ + const [event, template] = await Promise.all([ fetchBilingualEvent(strapi, eventReference), resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'), - readEmailBranding(strapi), ]); if (!event.en && !event.ar) { @@ -601,7 +591,6 @@ export async function sendRegistrationConfirmation({ en: { event: titleEn, date: dateEn }, }, locationLine: locationLineFor(event), - branding, jsonLd: buildEventReservationJsonLd(event, state, reservation), }); @@ -634,10 +623,9 @@ export async function sendEventReminderMail({ reservation: ReservationIdentity; }): Promise { try { - const [event, template, branding] = await Promise.all([ + const [event, template] = await Promise.all([ fetchBilingualEvent(strapi, eventReference), resolveEmailTemplate(strapi, 'event-reminder'), - readEmailBranding(strapi), ]); if (!event.en && !event.ar) { @@ -669,7 +657,6 @@ export async function sendEventReminderMail({ en: { event: titleEn, date: dateEn }, }, locationLine: locationLineFor(event), - branding, jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation), });