Add branded admin email creator

This commit is contained in:
Michael committed 2026-07-14 12:19:04 +02:00
1 parent 15bc6f063d
commit 6ebd701721
5 files changed
+281

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function lines(value) {
return esc(value).replace(/\r?\n/g, '<br>');
}
function paragraphBlocks(value) {
return String(value ?? '')
.split(/\r?\n\s*\r?\n/)
.filter(Boolean)
.map((paragraph, index, all) => `
<tr><td style="padding-bottom:${index === all.length - 1 ? 0 : 18}px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;mso-line-height-rule:exactly;color:#5A544E;">
${lines(paragraph)}
</td></tr>`)
.join('');
}
function ctaButton(url, label) {
if (!url || !label) return '';
return `
<table role="presentation" width="300" cellpadding="0" cellspacing="0" border="0" style="width:300px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td width="300" height="48" align="center" valign="middle" bgcolor="#C1272D" style="width:300px;height:48px;background-color:#C1272D;">
<a href="${esc(url)}" target="_blank" style="display:block;width:300px;line-height:48px;color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;text-align:center;text-decoration:none;">${esc(label)}</a>
</td></tr>
</table>`;
}
export function buildCustomEmail({ content, baseUrl }) {
const logoUrl = `${baseUrl}/assets/logo.jpg`;
const button = ctaButton(content.buttonUrl, content.buttonLabel);
const html = `<!doctype html>
<html lang="fr" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(content.subject)}</title>
<!--[if mso]>
<xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>
<style>table { border-collapse:collapse; } td { font-family:Arial,Helvetica,sans-serif; }</style>
<![endif]-->
</head>
<body bgcolor="#F2EEE8" style="margin:0;padding:0;background-color:#F2EEE8;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<table id="sbc-email-root" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F2EEE8" style="width:100%;background-color:#F2EEE8;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td align="center" valign="top" style="padding:36px 14px;">
<!--[if mso]><table role="presentation" width="620" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:620px;background-color:#FFFFFF;border:1px solid #E7E2DB;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td bgcolor="#161514" style="background-color:#161514;padding:26px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr>
<td width="52" valign="middle"><img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;background-color:#FFFFFF;"></td>
<td valign="middle" style="padding-left:16px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:22px;">Business Club</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;line-height:16px;">SLUC Nancy</div>
</td>
</tr></table>
</td></tr>
<tr><td style="padding:40px 40px 16px;">
${content.kicker ? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-bottom:16px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.5px;text-transform:uppercase;color:#C1272D;font-weight:bold;">${esc(content.kicker)}</td></tr></table>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-bottom:22px;font-family:Georgia,'Times New Roman',serif;font-size:28px;line-height:34px;color:#1B1B1B;">${esc(content.title)}</td></tr></table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">${paragraphBlocks(content.body)}</table>
</td></tr>
${button ? `<tr><td align="center" style="padding:18px 40px 12px;">${button}</td></tr>` : ''}
${content.signature ? `<tr><td style="padding:24px 40px 38px;font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:26px;color:#1B1B1B;">${lines(content.signature)}</td></tr>` : '<tr><td height="26" style="height:26px;line-height:26px;">&nbsp;</td></tr>'}
<tr><td bgcolor="#0F0E0D" style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:22px;">
Business Club SLUC Nancy &middot; Palais des Sports Jean Weille, Nancy<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr></table>
</body>
</html>`;
return { subject: content.subject, html };
}
+17
View File
@@ -13,6 +13,7 @@ import {
rencontrePasseeSchema,
pastPhotoParam,
emailComposeSchema,
customEmailSchema,
idParam,
} from '../schemas.js';
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
@@ -20,6 +21,7 @@ import { ensureMemberAccess } from '../memberAccess.js';
import { createStaffUser, resetStaffAccess } from '../userAccess.js';
import { AccessError } from '../errors.js';
import { buildInvitationEmail } from '../emailTemplate.js';
import { buildCustomEmail } from '../customEmailTemplate.js';
export const adminRouter = Router();
@@ -270,6 +272,21 @@ adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), asy
}
});
// General-purpose branded email composer, reserved to administrators.
adminRouter.post('/emails/preview', adminOnly, validate(customEmailSchema), async (req, res, next) => {
try {
const { base, ...content } = req.data;
let baseUrl = `${req.protocol}://${req.get('host')}`;
if (base) {
const parsed = new URL(base);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') baseUrl = parsed.origin;
}
res.json(buildCustomEmail({ content, baseUrl }));
} catch (err) {
next(err);
}
});
// ---------- Rencontres passées (admin + moderator manage; admin-only delete) ----------
// Powers the "Rencontres passées" section on the home page and its dedicated
// public page. Participant count is derived from the linked rencontre's
+14
View File
@@ -105,3 +105,17 @@ export const emailComposeSchema = z.object({
outro: z.string().trim().max(2000).optional(),
signature: z.string().trim().max(500).optional(),
});
export const customEmailSchema = z.object({
base: z.string().trim().url().max(300).optional(),
subject: trimmed(200, 1),
kicker: trimmed(120).optional().default(''),
title: trimmed(200, 1),
body: trimmed(6000, 1),
buttonLabel: trimmed(100).optional().default(''),
buttonUrl: z.string().trim().url().max(500).or(z.literal('')).optional().default(''),
signature: trimmed(500).optional().default(''),
}).refine((data) => Boolean(data.buttonLabel) === Boolean(data.buttonUrl), {
message: 'le texte et le lien du bouton doivent être renseignés ensemble',
path: ['buttonUrl'],
});