remove mail templates from CMS

This commit is contained in:
2026-10-07 09:33:08 +02:00
parent 1c0912378a
commit dcf4c85128
5 changed files with 43 additions and 283 deletions
@@ -202,7 +202,7 @@ const sanitizeSubmission: Core.MiddlewareFactory =
}); });
// Send the confirmation only after the transaction above has committed: // 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 // database, and running those reads inside the transaction would bind them
// to a transaction that is about to be committed, which knex rejects // to a transaction that is about to be committed, which knex rejects
// ("Transaction query already complete"). Dispatched without awaiting so a // ("Transaction query already complete"). Dispatched without awaiting so a
@@ -5,8 +5,8 @@
* transaction opened by the sanitize-submission middleware, and that middleware * transaction opened by the sanitize-submission middleware, and that middleware
* dispatches the confirmation e-mail *after* the transaction has committed. * dispatches the confirmation e-mail *after* the transaction has committed.
* It must not be dispatched from here: a send fired inside the request * It must not be dispatched from here: a send fired inside the request
* transaction would bind the mail helper's own reads (event, template, * transaction would bind the mail helper's own reads (event and template)
* branding) to a transaction that is about to be committed, which knex * to a transaction that is about to be committed, which knex
* rejects with "Transaction query already complete". * rejects with "Transaction query already complete".
*/ */
+2 -7
View File
@@ -23,10 +23,7 @@
import type { Core } from '@strapi/strapi'; import type { Core } from '@strapi/strapi';
import { import { resolveEmailTemplate } from './email-templates';
readEmailBranding,
resolveEmailTemplate,
} from './email-templates';
import { buildBilingualMail, trySendMail } from './event-mail'; import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity'; const OPPORTUNITY_UID = 'api::opportunity.opportunity';
@@ -88,10 +85,9 @@ export async function sendApplicationAcknowledgement({
opportunityReference: OpportunityReference; opportunityReference: OpportunityReference;
}): Promise<void> { }): Promise<void> {
try { try {
const [opportunity, template, branding] = await Promise.all([ const [opportunity, template] = await Promise.all([
fetchBilingualOpportunity(strapi, opportunityReference), fetchBilingualOpportunity(strapi, opportunityReference),
resolveEmailTemplate(strapi, 'application-acknowledgement'), resolveEmailTemplate(strapi, 'application-acknowledgement'),
readEmailBranding(strapi),
]); ]);
const callEn = opportunity.en?.title || undefined; const callEn = opportunity.en?.title || undefined;
@@ -106,7 +102,6 @@ export async function sendApplicationAcknowledgement({
ar: { call: callAr ?? '' }, ar: { call: callAr ?? '' },
en: { call: callEn ?? '' }, en: { call: callEn ?? '' },
}, },
branding,
}); });
const ok = await trySendMail(strapi, { to, ...mail }); const ok = await trySendMail(strapi, { to, ...mail });
+11 -233
View File
@@ -1,25 +1,13 @@
/** /**
* email-templates — resolve the CMS-configured copy and branding the outbound * email-templates — resolve hardcoded copy and shared placeholders for the
* mails are built from. * outbound mails.
* *
* The `email-settings` single type (created at boot with the shipped defaults) * The subjects and markdown bodies live in email-template-defaults.ts. This
* holds one subject + markdown body per template key, per locale. These * resolver returns those values directly; the CMS has no runtime copy source.
* resolvers turn that editable state into exactly the values an email needs:
* *
* resolveEmailTemplate the subject/body pair for a key, per locale, each * resolveEmailTemplate the subject/body pair for a key, per locale, each
* locale falling back to its own code default when * locale falling back to its own code default when
* the row is missing, switched off or unreadable. * 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 * fillPlaceholders the {event} / {date} / {call} substitution, shared
* by both mail modules. An empty value removes the * by both mail modules. An empty value removes the
* placeholder together with the word before it, so * placeholder together with the word before it, so
@@ -35,235 +23,25 @@ import {
type LocalisedEmailTemplate, type LocalisedEmailTemplate,
} from './email-template-defaults'; } 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 TEMPLATES
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
type MailTemplateSettings = { /** The hardcoded per-locale subject/body for one template key. */
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 = { export type ResolvedEmailTemplate = {
ar: LocalisedEmailTemplate; ar: LocalisedEmailTemplate;
en: LocalisedEmailTemplate; en: LocalisedEmailTemplate;
}; };
/** /**
* The subject and body for one template key, per locale. Each locale prefers * The subject and body for one template key, per locale. The retained Strapi
* its own CMS row and falls back to that locale's shipped default when the * argument keeps callers stable while all senders move to the static source.
* 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( export function resolveEmailTemplate(
strapi: Core.Strapi, _strapi: Core.Strapi,
key: EmailTemplateKey key: EmailTemplateKey
): Promise<ResolvedEmailTemplate> { ): ResolvedEmailTemplate {
const defaults = DEFAULT_EMAIL_TEMPLATES[key]; return 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;
}
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
+27 -40
View File
@@ -27,10 +27,8 @@ import type { Core } from '@strapi/strapi';
import { buildEventCalendar } from './event-calendar'; import { buildEventCalendar } from './event-calendar';
import { import {
readEmailBranding,
resolveEmailTemplate, resolveEmailTemplate,
fillPlaceholders, fillPlaceholders,
type EmailBranding,
type PlaceholderValues, type PlaceholderValues,
} from './email-templates'; } from './email-templates';
import { mdToHtml, mdToText, escapeHtml } from './markdown'; import { mdToHtml, mdToText, escapeHtml } from './markdown';
@@ -316,46 +314,41 @@ const FOOTER = {
const BASE_FONT = const BASE_FONT =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;"; "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 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 * (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 * text. EU visibility is contractual, so the badge and the partner logos are
* rendered as one unit straight from `site-settings`. Without an absolute * linked to the public assets on the project website.
* image URL the header degrades to the site name as a text wordmark.
*/ */
const renderBrandHeader = (): string => `
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}"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px; ${BASE_FONT}">
<tr> <tr>
<td style="padding:0; vertical-align:middle;">${logo}</td> <td style="padding:0; vertical-align:middle;"><img src="${EMAIL_LOGOS.project}" alt="SHARP-ER" style="height:40px; width:auto; display:block;" /></td>
<td align="right" style="padding:0; vertical-align:middle;">${badge}</td> <td align="right" style="padding:0; vertical-align:middle;"><img src="${EMAIL_LOGOS.eu}" alt="${escapeHtml('Funded by the European Union')}" style="height:36px; width:auto; display:block;" /></td>
</tr> </tr>
</table>`; </table>`;
};
const renderPartnerLogos = (branding?: EmailBranding): string => { const renderPartnerLogos = (): string => {
const logos = (branding?.partners ?? []).filter((partner) => partner.logo); const images = EMAIL_LOGOS.partners
if (logos.length === 0) return '';
const images = logos
.map( .map(
(partner) => (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;" />` `<img src="${partner.url}" 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(''); .join('');
@@ -388,7 +381,6 @@ export function buildBilingualMail(opts: {
/** Literal {event} / {date} / {call} values, per locale, unescaped. */ /** Literal {event} / {date} / {call} values, per locale, unescaped. */
vars?: { ar: PlaceholderValues; en: PlaceholderValues }; vars?: { ar: PlaceholderValues; en: PlaceholderValues };
locationLine?: { ar: string; en: string }; locationLine?: { ar: string; en: string };
branding?: EmailBranding;
jsonLd?: JsonLd; jsonLd?: JsonLd;
}): { subject: string; text: string; html: string } { }): { subject: string; text: string; html: string } {
const { const {
@@ -398,7 +390,6 @@ export function buildBilingualMail(opts: {
bodyEn, bodyEn,
vars, vars,
locationLine, locationLine,
branding,
jsonLd, jsonLd,
} = opts; } = opts;
@@ -412,7 +403,7 @@ export function buildBilingualMail(opts: {
const html = ` const html = `
${renderJsonLd(jsonLd)} ${renderJsonLd(jsonLd)}
${renderBrandHeader(branding)} ${renderBrandHeader()}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;"> <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"> <div dir="rtl" lang="ar">
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))} ${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
@@ -430,7 +421,7 @@ export function buildBilingualMail(opts: {
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}"> <p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
${FOOTER.en} ${FOOTER.en}
</p> </p>
${renderPartnerLogos(branding)}`; ${renderPartnerLogos()}`;
const text = [ const text = [
fillPlaceholders(mdToText(bodyAr), varsAr), fillPlaceholders(mdToText(bodyAr), varsAr),
@@ -562,10 +553,9 @@ export async function sendRegistrationConfirmation({
try { try {
const isWaitlist = state === 'waitlisted'; const isWaitlist = state === 'waitlisted';
const [event, template, branding] = await Promise.all([ const [event, template] = await Promise.all([
fetchBilingualEvent(strapi, eventReference), fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'), resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'),
readEmailBranding(strapi),
]); ]);
if (!event.en && !event.ar) { if (!event.en && !event.ar) {
@@ -601,7 +591,6 @@ export async function sendRegistrationConfirmation({
en: { event: titleEn, date: dateEn }, en: { event: titleEn, date: dateEn },
}, },
locationLine: locationLineFor(event), locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, state, reservation), jsonLd: buildEventReservationJsonLd(event, state, reservation),
}); });
@@ -634,10 +623,9 @@ export async function sendEventReminderMail({
reservation: ReservationIdentity; reservation: ReservationIdentity;
}): Promise<boolean> { }): Promise<boolean> {
try { try {
const [event, template, branding] = await Promise.all([ const [event, template] = await Promise.all([
fetchBilingualEvent(strapi, eventReference), fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, 'event-reminder'), resolveEmailTemplate(strapi, 'event-reminder'),
readEmailBranding(strapi),
]); ]);
if (!event.en && !event.ar) { if (!event.en && !event.ar) {
@@ -669,7 +657,6 @@ export async function sendEventReminderMail({
en: { event: titleEn, date: dateEn }, en: { event: titleEn, date: dateEn },
}, },
locationLine: locationLineFor(event), locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation), jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation),
}); });