Compare commits

...
2 Commits
Author SHA1 Message Date
Illusion 47d2a7fc3e fix(cms): hardcode styled email templates 2026-10-07 09:33:39 +02:00
Illusion dcf4c85128 remove mail templates from CMS 2026-10-07 09:33:08 +02:00
13 changed files with 96 additions and 575 deletions
+4 -4
View File
@@ -158,10 +158,10 @@ DEFAULT_REPLY_TO=support@yourdomain.com
# 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.
# Their bilingual copy is hardcoded in src/lib/email-template-defaults.ts.
# Every message uses the styled HTML layout in src/lib/event-mail.ts. The
# SHARP-ER / EU / partner logos need 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
@@ -1,31 +0,0 @@
{
"kind": "singleType",
"collectionName": "email_settings",
"info": {
"singularName": "email-settings",
"pluralName": "email-settings-list",
"displayName": "Email Settings",
"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."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"templates": {
"type": "component",
"repeatable": true,
"component": "email.template",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -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
@@ -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".
*/
-58
View File
@@ -1,58 +0,0 @@
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<void> => {
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.`
);
};
-3
View File
@@ -2,7 +2,6 @@ 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 { ensurePartnerReviewPermissions } from './partner-review-permissions';
@@ -11,7 +10,6 @@ import { ensurePartnerRelationLabels } from './partner-relation-labels';
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';
@@ -34,7 +32,6 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise
await ensurePublicPermissions(strapi);
await revokePublicAuthActions(strapi);
await ensureReminderSettings(strapi);
await ensureEmailSettings(strapi);
await ensurePartnerReviewPermissions(strapi);
await ensurePartnerReviewLayouts(strapi);
await ensurePartnerRelationLabels(strapi);
@@ -1,35 +0,0 @@
{
"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
}
}
}
+7 -15
View File
@@ -11,22 +11,18 @@
* retried by nothing today but is visible in the admin panel as a null.
*
* The (Get Involved) section form can submit an application with no
* opportunity relation. The mail then drops the call name and stays a plain
* acknowledgement, so the promise holds for both entry points.
* opportunity relation. The mail then drops the call name and uses the
* no-call acknowledgement, so the promise holds for both entry points.
*
* 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}`
* Copy is hardcoded in `email-template-defaults.ts`, and this message uses the
* same styled HTML layout as the event emails. 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 { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { buildBilingualMail, trySendMail } from './event-mail';
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
@@ -88,11 +84,8 @@ export async function sendApplicationAcknowledgement({
opportunityReference: OpportunityReference;
}): Promise<void> {
try {
const [opportunity, template, branding] = await Promise.all([
fetchBilingualOpportunity(strapi, opportunityReference),
resolveEmailTemplate(strapi, 'application-acknowledgement'),
readEmailBranding(strapi),
]);
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
const template = DEFAULT_EMAIL_TEMPLATES['application-acknowledgement'];
const callEn = opportunity.en?.title || undefined;
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
@@ -106,7 +99,6 @@ export async function sendApplicationAcknowledgement({
ar: { call: callAr ?? '' },
en: { call: callEn ?? '' },
},
branding,
});
const ok = await trySendMail(strapi, { to, ...mail });
+7 -11
View File
@@ -1,12 +1,9 @@
/**
* email-template-defaults — the shipped subject and body for every email the
* CMS sends.
* email-template-defaults — the hardcoded 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.
* This is the source of truth for outbound email copy. Changing a template
* requires a code change and review; the CMS does not store editable copies.
*
* 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
@@ -37,8 +34,8 @@ export type LocalisedEmailTemplate = {
/**
* 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.
* the no-call variant is what placeholder collapse produces when there is no
* opportunity.
*/
const ACKNOWLEDGEMENT_WITH_CALL = {
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
@@ -46,8 +43,7 @@ const ACKNOWLEDGEMENT_WITH_CALL = {
} as const;
/**
* The plain acknowledgement (no call), reproduced verbatim so editors can
* compare against the collapsed form when the placeholder is empty.
* The plain acknowledgement (no call), reproduced verbatim for reference.
*/
const ACKNOWLEDGEMENT_WITHOUT_CALL = {
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
+2 -269
View File
@@ -1,275 +1,8 @@
/**
* 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).
* email-templates — shared placeholder interpolation for hardcoded outbound
* email templates. Subjects and bodies live in email-template-defaults.ts.
*/
import type { Core } from '@strapi/strapi';
import {
DEFAULT_EMAIL_TEMPLATES,
type EmailTemplateKey,
type LocalisedEmailTemplate,
} from './email-template-defaults';
const EMAIL_SETTINGS_UID = 'api::email-settings.email-settings';
const SITE_SETTINGS_UID = 'api::site-settings.site-settings';
const LOG_PREFIX = '[email-templates]';
/* ---------------------------------------------------------------------------
TEMPLATES
------------------------------------------------------------------------ */
type MailTemplateSettings = {
templates?: unknown;
};
/**
* Find the row for `key` on a `email-settings` document read for one locale.
* Returns `undefined` when there is no such row, it is switched off, or its
* fields are unusable — the caller then falls back to that locale's default.
*/
function templateRowFor(
settings: unknown,
key: EmailTemplateKey
): LocalisedEmailTemplate | undefined {
if (!settings || typeof settings !== 'object') return undefined;
const templates = (settings as MailTemplateSettings).templates;
if (!Array.isArray(templates)) return undefined;
for (const row of templates) {
if (!row || typeof row !== 'object') continue;
const candidate = row as {
key?: unknown;
enabled?: unknown;
subject?: unknown;
body?: unknown;
};
if (candidate.key !== key) continue;
if (candidate.enabled === false) return undefined;
if (typeof candidate.subject === 'string' && typeof candidate.body === 'string') {
return { subject: candidate.subject, body: candidate.body };
}
return undefined;
}
return undefined;
}
/** The per-locale subject/body for one template key, resolved from the CMS. */
export type ResolvedEmailTemplate = {
ar: LocalisedEmailTemplate;
en: LocalisedEmailTemplate;
};
/**
* The subject and body for one template key, per locale. Each locale prefers
* its own CMS row and falls back to that locale's shipped default when the
* row is missing, disabled or the single type is unreadable — so a broken or
* empty `email-settings` never stops a mail from going out.
*/
export async function resolveEmailTemplate(
strapi: Core.Strapi,
key: EmailTemplateKey
): Promise<ResolvedEmailTemplate> {
const defaults = DEFAULT_EMAIL_TEMPLATES[key];
const resolved: ResolvedEmailTemplate = {
ar: { subject: defaults.ar.subject, body: defaults.ar.body },
en: { subject: defaults.en.subject, body: defaults.en.body },
};
try {
const [arSettings, enSettings] = await Promise.all([
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'ar' }),
strapi.documents(EMAIL_SETTINGS_UID).findFirst({ locale: 'en' }),
]);
const arRow = templateRowFor(arSettings, key);
const enRow = templateRowFor(enSettings, key);
if (arRow) resolved.ar = arRow;
if (enRow) resolved.en = enRow;
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read email-settings for "${key}", using defaults: ${String(error)}`
);
}
return resolved;
}
/* ---------------------------------------------------------------------------
BRANDING (SHARP-ER + EU + consortium logos)
------------------------------------------------------------------------ */
/**
* The `site-settings` media shapes, read with a loose internal eye so the
* population details of the findFirst populate above cannot break the mail.
*/
type RawMedia = { url?: unknown } | null | undefined;
type RawPartner = { name?: unknown; logo?: RawMedia } | undefined;
/** A partner that will appear in the mail's logo block. */
export type EmailPartner = { name: string; logo: string | null };
/** Everything the mail chrome needs, resolved to absolute image URLs. */
export type EmailBranding = {
siteName: string;
projectLogo: string | null;
euBadge: string | null;
partners: EmailPartner[];
};
const EMPTY_BRANDING: EmailBranding = {
siteName: 'SHARP-ER',
projectLogo: null,
euBadge: null,
partners: [],
};
/**
* The brand assets shipped with the CMS, copied from the site's own
* `apps/web/public/assets` into `public/branding`. They are the fallback when
* `site-settings` has no logo uploaded yet, so every mail still carries the
* SHARP-ER wordmark, the EU emblem and the consortium logos (Acted, UEC, SBE —
* always together, never one partner alone). `site-settings` media still wins
* when an editor uploads it.
*/
const BUNDLED_BRANDING = {
projectLogo: '/branding/sharp-er-wordmark.png',
euBadge: '/branding/eu-emblem.png',
partners: [
{ name: 'Acted', logo: '/branding/partner-acted.png' },
{ name: 'United Efforts Consultancy (UEC)', logo: '/branding/partner-uec.png' },
{ name: 'Stichting Bureau Ensemble (SBE)', logo: '/branding/partner-sbe.png' },
],
} as const;
/**
* The absolute origin the brand images are resolved against: `server.url`
* (PUBLIC_URL) when configured, the local server in development, and `null`
* otherwise. Mail clients need absolute URLs or the logos do not load; on
* production behind a custom domain, leaving PUBLIC_URL unset drops the
* images and degrades the mail to the plain text wordmark.
*/
const emailBase = (strapi: Core.Strapi): string | null => {
const configured = strapi.config.get('server.url') as string | undefined;
if (configured && configured.trim().length > 0) return configured;
if ((process.env.NODE_ENV ?? 'development') !== 'production') {
return `http://localhost:${strapi.config.get('server.port') ?? 1337}`;
}
return null;
};
/** Resolve a media URL to an absolute one, or null when that is impossible. */
const absoluteMediaUrl = (base: string | undefined, url: unknown): string | null => {
if (typeof url !== 'string' || url.length === 0) return null;
try {
return new URL(url, base).href;
} catch {
return null;
}
};
/**
* Read the logos for the mail chrome: the SHARP-ER project logo, the EU
* funding badge and the consortium partner logos (Acted, UEC, SBE). It prefers
* the `site-settings` media and falls back to the brand assets bundled with the
* CMS (public/branding) when nothing is uploaded, so the mail only degrades to
* a text wordmark when there is no absolute base URL to resolve images
* against. The badge and the partner logos always render as one unit — EU
* visibility is contractual and a partner logo never appears on its own.
*/
export async function readEmailBranding(strapi: Core.Strapi): Promise<EmailBranding> {
let siteName = EMPTY_BRANDING.siteName;
try {
const settings = await strapi.documents(SITE_SETTINGS_UID).findFirst({
populate: {
logo: true,
euBadge: true,
consortiumLogos: { populate: { logo: true } },
},
});
if (settings && typeof settings.siteName === 'string') {
siteName = settings.siteName;
}
const base = emailBase(strapi);
if (!base) {
strapi.log.warn(
`${LOG_PREFIX} no absolute base URL (PUBLIC_URL) and not in development; e-mails will use the text wordmark and omit logos.`
);
return { siteName, projectLogo: null, euBadge: null, partners: [] };
}
const mediaUrl = (file: RawMedia): string | null =>
absoluteMediaUrl(base, file?.url);
const bundledUrl = (assetPath: string): string | null =>
absoluteMediaUrl(base, assetPath);
const toPartner = (partner: unknown): EmailPartner => {
const p = partner as RawPartner | undefined;
return {
name: typeof p?.name === 'string' ? p.name : '',
logo: mediaUrl(p?.logo),
};
};
const uploadedPartners: EmailPartner[] = Array.isArray(settings?.consortiumLogos)
? (settings.consortiumLogos as unknown[]).map(toPartner)
: [];
const partners: EmailPartner[] =
uploadedPartners.length > 0
? uploadedPartners
: BUNDLED_BRANDING.partners.map((partner) => ({
name: partner.name,
logo: bundledUrl(partner.logo),
}));
return {
siteName,
projectLogo: mediaUrl(settings?.logo) ?? bundledUrl(BUNDLED_BRANDING.projectLogo),
euBadge: mediaUrl(settings?.euBadge) ?? bundledUrl(BUNDLED_BRANDING.euBadge),
partners,
};
} catch (error) {
strapi.log.warn(
`${LOG_PREFIX} could not read site-settings branding, emails will use the text wordmark: ${String(error)}`
);
return EMPTY_BRANDING;
}
}
/* ---------------------------------------------------------------------------
PLACEHOLDERS
------------------------------------------------------------------------ */
/** Literal values for {event} / {date} / {call}, per locale, unescaped. */
export type PlaceholderValues = Readonly<Record<string, string>>;
+62 -67
View File
@@ -14,25 +14,16 @@
* The Arabic block always comes first and is marked dir="rtl"; the English
* block follows inside the same message. Subjects are bilingual too.
*
* 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`).
* COPY IS HARDCODED in lib/email-template-defaults.ts. This module owns the
* shared styled HTML layout, event lookups, the {event} / {date} placeholders,
* venue or join-online line, and EU/partner branding.
*/
import type { Core } from '@strapi/strapi';
import { buildEventCalendar } from './event-calendar';
import {
readEmailBranding,
resolveEmailTemplate,
fillPlaceholders,
type EmailBranding,
type PlaceholderValues,
} from './email-templates';
import { DEFAULT_EMAIL_TEMPLATES } from './email-template-defaults';
import { fillPlaceholders, type PlaceholderValues } from './email-templates';
import { mdToHtml, mdToText, escapeHtml } from './markdown';
const EVENT_UID = 'api::event.event';
@@ -316,46 +307,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 +374,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 +383,6 @@ export function buildBilingualMail(opts: {
bodyEn,
vars,
locationLine,
branding,
jsonLd,
} = opts;
@@ -410,27 +394,46 @@ export function buildBilingualMail(opts: {
Object.entries(values).map(([name, value]) => [name, escapeHtml(value)])
);
const html = `
const subject = `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`;
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(subject)}</title>
${renderJsonLd(jsonLd)}
${renderBrandHeader(branding)}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.6; color:#222;">
<div dir="rtl" lang="ar">
</head>
<body style="margin:0; padding:0; background-color:#f2f5f3; ${BASE_FONT}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#f2f5f3" style="width:100%; border-collapse:collapse; background-color:#f2f5f3;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#ffffff" style="width:100%; max-width:640px; border:1px solid #dfe6e1; border-top:4px solid #356b57; border-radius:8px; background-color:#ffffff; border-collapse:separate; border-spacing:0;">
<tr><td style="padding:28px 32px 24px; ${BASE_FONT}">
${renderBrandHeader()}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${BASE_FONT}"><tr><td style="padding:0; line-height:1.7; color:#25312b; font-size:16px;">
<div dir="rtl" lang="ar" style="direction:rtl; text-align:right;">
${fillPlaceholders(mdToHtml(bodyAr), escaped(varsAr))}
${locationHtml(locationLine?.ar)}
</div>
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" />
<div dir="ltr" lang="en">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0;"><tr><td style="border-top:1px solid #e2e8e4; font-size:0; line-height:0;">&nbsp;</td></tr></table>
<div dir="ltr" lang="en" style="direction:ltr; text-align:left;">
${fillPlaceholders(mdToHtml(bodyEn), escaped(varsEn))}
${locationHtml(locationLine?.en)}
</div>
</td></tr></table>
<p style="margin-top:32px; font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
<p style="margin:32px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.ar}
</p>
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
<p style="margin:8px 0 0; font-size:12px; line-height:1.5; color:#66716b; ${BASE_FONT}">
${FOOTER.en}
</p>
${renderPartnerLogos(branding)}`;
${renderPartnerLogos()}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = [
fillPlaceholders(mdToText(bodyAr), varsAr),
@@ -446,7 +449,7 @@ export function buildBilingualMail(opts: {
.join('\n');
return {
subject: `${fillPlaceholders(subjectAr, varsAr)} | ${fillPlaceholders(subjectEn, varsEn)}`,
subject,
text,
html,
};
@@ -498,7 +501,7 @@ type MailOptions = {
to: string;
subject: string;
text: string;
html?: string;
html: string;
attachments?: MailAttachment[];
};
@@ -562,11 +565,8 @@ export async function sendRegistrationConfirmation({
try {
const isWaitlist = state === 'waitlisted';
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, isWaitlist ? 'waitlist' : 'confirmation'),
readEmailBranding(strapi),
]);
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES[isWaitlist ? 'waitlist' : 'confirmation'];
if (!event.en && !event.ar) {
strapi.log.warn(
@@ -601,7 +601,6 @@ export async function sendRegistrationConfirmation({
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, state, reservation),
});
@@ -634,11 +633,8 @@ export async function sendEventReminderMail({
reservation: ReservationIdentity;
}): Promise<boolean> {
try {
const [event, template, branding] = await Promise.all([
fetchBilingualEvent(strapi, eventReference),
resolveEmailTemplate(strapi, 'event-reminder'),
readEmailBranding(strapi),
]);
const event = await fetchBilingualEvent(strapi, eventReference);
const template = DEFAULT_EMAIL_TEMPLATES['event-reminder'];
if (!event.en && !event.ar) {
strapi.log.warn(
@@ -669,7 +665,6 @@ export async function sendEventReminderMail({
en: { event: titleEn, date: dateEn },
},
locationLine: locationLineFor(event),
branding,
jsonLd: buildEventReservationJsonLd(event, 'confirmed', reservation),
});
-28
View File
@@ -110,33 +110,6 @@ 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<true>;
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: {
@@ -393,7 +366,6 @@ 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;
-40
View File
@@ -852,45 +852,6 @@ 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';
@@ -2869,7 +2830,6 @@ 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;