···11+{
22+ // Place your churros workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
33+ // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
44+ // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
55+ // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
66+ // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
77+ // Placeholders with the same ids are connected.
88+ // Example:
99+ // "Print to console": {
1010+ // "scope": "javascript,typescript",
1111+ // "prefix": "log",
1212+ // "body": [
1313+ // "console.log('$1');",
1414+ // "$2"
1515+ // ],
1616+ // "description": "Log output to console"
1717+ // }
1818+ "MJML template": {
1919+ "scope": "mjml",
2020+ "prefix": "mailtemplate",
2121+ "body": [
2222+ "<mjml>",
2323+ "\t<mj-head>",
2424+ "\t\t<mj-title>$1</mj-title>",
2525+ "\t\t<mj-include path=\"./_head.mjml\"></mj-include>",
2626+ "\t</mj-head>",
2727+ "\t<mj-body>",
2828+ "\t\t<mj-include path=\"./_header.mjml\"></mj-include>",
2929+ "",
3030+ "\t\t<mj-section>",
3131+ "\t\t\t<mj-column>",
3232+ "\t\t\t\t<mj-text>",
3333+ "\t\t\t\t\t<h1>$1</h1>",
3434+ "\t\t\t\t</mj-text>",
3535+ "\t\t\t\t<mj-text>",
3636+ "\t\t\t\t\t$0",
3737+ "\t\t\t\t</mj-text>",
3838+ "\t\t\t</mj-column>",
3939+ "\t\t</mj-section>",
4040+ "",
4141+ "\t\t<mj-include path=\"./_footer.mjml\"></mj-include>",
4242+ "\t</mj-body>",
4343+ "</mjml>",
4444+ ],
4545+ },
4646+}
+4
CHANGELOG.md
···11111212## [Unreleased]
13131414+### Améliorations
1515+1616+- Les mails sont jolis maintenant :)
1717+1418## [1.58.5] - 2024-05-29
15191620### Technique
···1515export * from './ldap-school.js';
1616export * from './ldap.js';
1717export * from './logger.js';
1818+export * from './mail.js';
1819export * from './markdown.js';
1920export * from './pictures.js';
2021export * from './prisma.js';
+149
packages/api/src/lib/mail.ts
···11+import Handlebars from 'handlebars';
22+import { htmlToText } from 'html-to-text';
33+import mjml2html from 'mjml';
44+import type { MJMLJsonObject, MJMLParseError } from 'mjml-core';
55+import { readFile, readdir } from 'node:fs/promises';
66+import path from 'node:path';
77+import { createTransport } from 'nodemailer';
88+import type Mail from 'nodemailer/lib/mailer/index.js';
99+import {
1010+ isMJMLTemplateFilename,
1111+ mailTemplatesDirectory,
1212+ type MailProps,
1313+ type MailRequiredContentIDs,
1414+ type MailTemplate,
1515+} from '../mail-templates/props.js';
1616+1717+const compiledTemplates = await precompileTemplates();
1818+const mailer = createTransport(process.env.SMTP_URL);
1919+2020+/**
2121+ * Maps attachments to their CIDs
2222+ */
2323+type AttachmentsMap<K extends string | number | symbol> = {
2424+ [cid in K]: Omit<Mail.Attachment, 'cid'>;
2525+};
2626+2727+/**
2828+ *
2929+ * @param templateName the template name. See src/mail-templates/
3030+ * @param to Who to send the mail to
3131+ * @param data The data to give. Should be pretty explicit from the type
3232+ * @param options.from The sender of the mail
3333+ * @param options.subjectOverride Override the subject of the mail. By default, the subject is the email's <title>
3434+ * @param options.attachments Attachments to add to the mail. Typing should help you to know what attachments are required
3535+ */
3636+export async function sendMail<Template extends MailTemplate>(
3737+ templateName: Template,
3838+ to: string | string[],
3939+ data: Template extends keyof MailProps ? MailProps[Template] : Record<string, never>,
4040+ {
4141+ from = process.env.PUBLIC_SUPPORT_EMAIL,
4242+ attachments = {},
4343+ subjectOverride = '',
4444+ }: {
4545+ from?: string;
4646+ subjectOverride?: string;
4747+ } & (Template extends keyof MailRequiredContentIDs
4848+ ? {
4949+ attachments: AttachmentsMap<MailRequiredContentIDs[Template][number]> &
5050+ AttachmentsMap<string>;
5151+ }
5252+ : {
5353+ // when no attachments are required, only additional attachments, or no attachments at all
5454+ attachments?: AttachmentsMap<string>;
5555+ }),
5656+) {
5757+ const template = compiledTemplates.get(templateName);
5858+ if (!template) throw new Error(`Template "${template}" not found`);
5959+6060+ const content = template.html({ to, ...data, env: process.env });
6161+ const subject = subjectOverride || template.subject({ to, ...data });
6262+6363+ await mailer.sendMail({
6464+ to,
6565+ from,
6666+ subject,
6767+ html: content,
6868+ text: htmlToText(content),
6969+ attachments: Object.entries(attachments).map(([cid, data]) => ({
7070+ cid,
7171+ ...data,
7272+ })),
7373+ });
7474+}
7575+7676+type PrecompiledTemplate = {
7777+ html: HandlebarsTemplateDelegate<unknown>;
7878+ subject: HandlebarsTemplateDelegate<unknown>;
7979+};
8080+8181+async function precompileTemplates(): Promise<Map<MailTemplate, PrecompiledTemplate>> {
8282+ const compiledTemplates = new Map<MailTemplate, PrecompiledTemplate>();
8383+ const compilationErrors = new Map<string, string[]>();
8484+ for (const templateFilename of await readdir(mailTemplatesDirectory)) {
8585+ if (!isMJMLTemplateFilename(templateFilename)) continue;
8686+ try {
8787+ const result = await compileTemplate(path.join(mailTemplatesDirectory, templateFilename));
8888+8989+ if ('errors' in result) {
9090+ compilationErrors.set(
9191+ templateFilename,
9292+ result.errors.map((e) => e.formattedMessage),
9393+ );
9494+ } else {
9595+ compiledTemplates.set(path.basename(templateFilename, '.mjml') as MailTemplate, result);
9696+ }
9797+ } catch (error) {
9898+ compilationErrors.set(templateFilename, [error?.toString() ?? '']);
9999+ }
100100+ }
101101+ if (compilationErrors.size > 0) {
102102+ console.error(
103103+ 'Error compiling templates:\n' +
104104+ [...compilationErrors.entries()]
105105+ .map(([filename, errors]) => `${filename}:\n${errors.join('\n')}`)
106106+ .join('\n'),
107107+ );
108108+ }
109109+ console.info(
110110+ `Successfully compiled ${compiledTemplates.size} mail templates: ${[...compiledTemplates.keys()].join(', ')}`,
111111+ );
112112+ return compiledTemplates;
113113+}
114114+115115+/**
116116+ * Rust FTW
117117+ */
118118+type Result<T, E> = T | { errors: E[] };
119119+120120+async function compileTemplate(
121121+ filepath: string,
122122+): Promise<Result<PrecompiledTemplate, MJMLParseError>> {
123123+ const content = await readFile(filepath, 'utf8');
124124+ if (!content.trim()) {
125125+ console.warn(`${path.basename(filepath)} is an empty MJML template`);
126126+ return {
127127+ html: Handlebars.compile(''),
128128+ subject: Handlebars.compile(''),
129129+ };
130130+ }
131131+ const rendered = mjml2html(content, { filePath: filepath });
132132+ if (rendered.errors.length > 0) return { errors: rendered.errors };
133133+134134+ return {
135135+ html: Handlebars.compile(rendered.html),
136136+ subject: Handlebars.compile(extractMjTitle(rendered.json)),
137137+ };
138138+}
139139+140140+function extractMjTitle(json: MJMLJsonObject): string {
141141+ if ('children' in json) {
142142+ const head = json.children.find((c) => c.tagName === 'mj-head');
143143+ if (head && 'children' in head) {
144144+ const title = head.children.find((c) => c.tagName === 'mj-title');
145145+ if (title && 'content' in title) return title.content;
146146+ }
147147+ }
148148+ throw new Error('No title found in MJML template');
149149+}
+5
packages/api/src/mail-templates/README.md
···11+# Templates mails
22+33+Utilise [MJML](https://mjml.io/) pour faire des jolis mails.
44+55+Ils ont un playground pratique ici: <https://mjml.io/try-it-live>
···1010import ldap from 'ldapjs';
1111import { createTransport } from 'nodemailer';
1212import { HealthCheck } from '../index.js';
1313+1314// TODO maybe rename to query.check-health ?
1414-// TODO centralize the mailer object in #lib
1515-1615builder.queryField('healthcheck', (t) =>
1716 t.field({
1817 type: HealthCheck,
···11-import { builder, prisma } from '#lib';
11+import { builder, prisma, sendMail } from '#lib';
22import { GraphQLError } from 'graphql';
33-44-import { createTransport } from 'nodemailer';
53import { prismaUserFilterForStudentAssociationAdmins } from '../utils/index.js';
6475// TODO rename registration to reject-user-candidate
88-96builder.mutationField('refuseRegistration', (t) =>
107 t.field({
118 authScopes: { studentAssociationAdmin: true },
···1916 });
2017 if (!candidate) throw new GraphQLError('Candidat·e introuvable');
21182222- const mailer = createTransport(process.env.SMTP_URL);
2323- await mailer.sendMail({
2424- to: email,
2525- from: process.env.PUBLIC_SUPPORT_EMAIL,
2626- subject: 'Inscription refusée',
2727- text: `Votre inscription a été refusée pour la raison suivante:\n\n ${reason}\n\n Si vous pensez qu'il s'agit d'une erreur, répondez à ce mail.`,
2828- html: `<p>Votre inscription a été refusée pour la raison suivante:<br><br> ${reason}<br><br> Si vous pensez qu'il s'agit d'une erreur, répondez à ce mail</p>`,
2929- });
1919+ await sendMail('signup-rejected', email, { reason }, {});
3020 candidate = await prisma.userCandidate.delete({ where: { email } });
2121+3122 await prisma.logEntry.create({
3223 data: {
3324 action: 'refuse',