remove mail templates from CMS
This commit is contained in:
@@ -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<void> {
|
||||
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 });
|
||||
|
||||
@@ -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<ResolvedEmailTemplate> {
|
||||
const defaults = DEFAULT_EMAIL_TEMPLATES[key];
|
||||
const resolved: ResolvedEmailTemplate = {
|
||||
ar: { subject: defaults.ar.subject, body: defaults.ar.body },
|
||||
en: { subject: defaults.en.subject, body: defaults.en.body },
|
||||
};
|
||||
|
||||
try {
|
||||
const [arSettings, enSettings] = await Promise.all([
|
||||
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'ar' }),
|
||||
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'en' }),
|
||||
]);
|
||||
|
||||
const arRow = templateRowFor(arSettings, key);
|
||||
const enRow = templateRowFor(enSettings, key);
|
||||
if (arRow) resolved.ar = arRow;
|
||||
if (enRow) resolved.en = enRow;
|
||||
} catch (error) {
|
||||
strapi.log.warn(
|
||||
`${LOG_PREFIX} could not read email-settings for "${key}", using defaults: ${String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
BRANDING (SHARP-ER + EU + consortium logos)
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The `site-settings` media shapes, read with a loose internal eye so the
|
||||
* population details of the findFirst populate above cannot break the mail.
|
||||
*/
|
||||
type RawMedia = { url?: unknown } | null | undefined;
|
||||
type RawPartner = { name?: unknown; logo?: RawMedia } | undefined;
|
||||
|
||||
/** A partner that will appear in the mail's logo block. */
|
||||
export type EmailPartner = { name: string; logo: string | null };
|
||||
|
||||
/** Everything the mail chrome needs, resolved to absolute image URLs. */
|
||||
export type EmailBranding = {
|
||||
siteName: string;
|
||||
projectLogo: string | null;
|
||||
euBadge: string | null;
|
||||
partners: EmailPartner[];
|
||||
};
|
||||
|
||||
const EMPTY_BRANDING: EmailBranding = {
|
||||
siteName: 'SHARP-ER',
|
||||
projectLogo: null,
|
||||
euBadge: null,
|
||||
partners: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* The brand assets shipped with the CMS, copied from the site's own
|
||||
* `apps/web/public/assets` into `public/branding`. They are the fallback when
|
||||
* `site-settings` has no logo uploaded yet, so every mail still carries the
|
||||
* SHARP-ER wordmark, the EU emblem and the consortium logos (Acted, UEC, SBE —
|
||||
* always together, never one partner alone). `site-settings` media still wins
|
||||
* when an editor uploads it.
|
||||
*/
|
||||
const BUNDLED_BRANDING = {
|
||||
projectLogo: '/branding/sharp-er-wordmark.png',
|
||||
euBadge: '/branding/eu-emblem.png',
|
||||
partners: [
|
||||
{ name: 'Acted', logo: '/branding/partner-acted.png' },
|
||||
{ name: 'United Efforts Consultancy (UEC)', logo: '/branding/partner-uec.png' },
|
||||
{ name: 'Stichting Bureau Ensemble (SBE)', logo: '/branding/partner-sbe.png' },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The absolute origin the brand images are resolved against: `server.url`
|
||||
* (PUBLIC_URL) when configured, the local server in development, and `null`
|
||||
* otherwise. Mail clients need absolute URLs or the logos do not load; on
|
||||
* production behind a custom domain, leaving PUBLIC_URL unset drops the
|
||||
* images and degrades the mail to the plain text wordmark.
|
||||
*/
|
||||
const emailBase = (strapi: Core.Strapi): string | null => {
|
||||
const configured = strapi.config.get('server.url') as string | undefined;
|
||||
if (configured && configured.trim().length > 0) return configured;
|
||||
if ((process.env.NODE_ENV ?? 'development') !== 'production') {
|
||||
return `http://localhost:${strapi.config.get('server.port') ?? 1337}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Resolve a media URL to an absolute one, or null when that is impossible. */
|
||||
const absoluteMediaUrl = (base: string | undefined, url: unknown): string | null => {
|
||||
if (typeof url !== 'string' || url.length === 0) return null;
|
||||
try {
|
||||
return new URL(url, base).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the logos for the mail chrome: the SHARP-ER project logo, the EU
|
||||
* funding badge and the consortium partner logos (Acted, UEC, SBE). It prefers
|
||||
* the `site-settings` media and falls back to the brand assets bundled with the
|
||||
* CMS (public/branding) when nothing is uploaded, so the mail only degrades to
|
||||
* a text wordmark when there is no absolute base URL to resolve images
|
||||
* against. The badge and the partner logos always render as one unit — EU
|
||||
* visibility is contractual and a partner logo never appears on its own.
|
||||
*/
|
||||
export async function readEmailBranding(strapi: Core.Strapi): Promise<EmailBranding> {
|
||||
let siteName = EMPTY_BRANDING.siteName;
|
||||
try {
|
||||
const settings = await strapi.documents(SITE_SETTINGS_UID).findFirst({
|
||||
populate: {
|
||||
logo: true,
|
||||
euBadge: true,
|
||||
consortiumLogos: { populate: { logo: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (settings && typeof settings.siteName === 'string') {
|
||||
siteName = settings.siteName;
|
||||
}
|
||||
|
||||
const base = emailBase(strapi);
|
||||
if (!base) {
|
||||
strapi.log.warn(
|
||||
`${LOG_PREFIX} no absolute base URL (PUBLIC_URL) and not in development; e-mails will use the text wordmark and omit logos.`
|
||||
);
|
||||
return { siteName, projectLogo: null, euBadge: null, partners: [] };
|
||||
}
|
||||
|
||||
const mediaUrl = (file: RawMedia): string | null =>
|
||||
absoluteMediaUrl(base, file?.url);
|
||||
const bundledUrl = (assetPath: string): string | null =>
|
||||
absoluteMediaUrl(base, assetPath);
|
||||
|
||||
const toPartner = (partner: unknown): EmailPartner => {
|
||||
const p = partner as RawPartner | undefined;
|
||||
return {
|
||||
name: typeof p?.name === 'string' ? p.name : '',
|
||||
logo: mediaUrl(p?.logo),
|
||||
};
|
||||
};
|
||||
|
||||
const uploadedPartners: EmailPartner[] = Array.isArray(settings?.consortiumLogos)
|
||||
? (settings.consortiumLogos as unknown[]).map(toPartner)
|
||||
: [];
|
||||
|
||||
const partners: EmailPartner[] =
|
||||
uploadedPartners.length > 0
|
||||
? uploadedPartners
|
||||
: BUNDLED_BRANDING.partners.map((partner) => ({
|
||||
name: partner.name,
|
||||
logo: bundledUrl(partner.logo),
|
||||
}));
|
||||
|
||||
return {
|
||||
siteName,
|
||||
projectLogo: mediaUrl(settings?.logo) ?? bundledUrl(BUNDLED_BRANDING.projectLogo),
|
||||
euBadge: mediaUrl(settings?.euBadge) ?? bundledUrl(BUNDLED_BRANDING.euBadge),
|
||||
partners,
|
||||
};
|
||||
} catch (error) {
|
||||
strapi.log.warn(
|
||||
`${LOG_PREFIX} could not read site-settings branding, emails will use the text wordmark: ${String(error)}`
|
||||
);
|
||||
return EMPTY_BRANDING;
|
||||
}
|
||||
): ResolvedEmailTemplate {
|
||||
return DEFAULT_EMAIL_TEMPLATES[key];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
? `<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 `
|
||||
const renderBrandHeader = (): string => `
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px; ${BASE_FONT}">
|
||||
<tr>
|
||||
<td style="padding:0; vertical-align:middle;">${logo}</td>
|
||||
<td align="right" style="padding:0; vertical-align:middle;">${badge}</td>
|
||||
<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;"><img src="${EMAIL_LOGOS.eu}" alt="${escapeHtml('Funded by the European Union')}" style="height:36px; width:auto; display:block;" /></td>
|
||||
</tr>
|
||||
</table>`;
|
||||
};
|
||||
|
||||
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) =>
|
||||
`<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('');
|
||||
|
||||
@@ -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()}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
|
||||
<div dir="rtl" lang="ar">
|
||||
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
|
||||
@@ -430,7 +421,7 @@ export function buildBilingualMail(opts: {
|
||||
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
|
||||
${FOOTER.en}
|
||||
</p>
|
||||
${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<boolean> {
|
||||
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),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user