PronounsPage/server/mailer.ts
Andrea Vos af3ac179b3 (lint)
2024-12-31 19:36:03 +01:00

260 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'fs';
import type { Readable } from 'stream';
import * as Sentry from '@sentry/node';
import nodemailer from 'nodemailer';
import type StreamTransport from 'nodemailer/lib/stream-transport/index.d.ts';
import type { Translations } from '../locale/translations.ts';
import { loadSuml, loadSumlFromBase } from './loader.ts';
import { rootDir } from './paths.ts';
const color = '#C71585';
const logo = fs.readFileSync(`${rootDir}/public/logo/logo-primary.svg`).toString('utf-8');
const logoEncoded = `data:image/svg+xml,${encodeURIComponent(logo)}`;
const translations = loadSuml('translations') as Translations;
const fallbackTranslations = loadSumlFromBase('locale/_base/translations') as Translations;
let transport: string | StreamTransport.Options = process.env.MAILER_TRANSPORT!;
if (!transport && process.env.NODE_ENV === 'development') {
transport = { streamTransport: true };
}
const transporter = nodemailer.createTransport(transport);
const sendEmail = (
to: string,
subject: string,
text: string | undefined = undefined,
html: string | undefined = undefined,
from: string | undefined = undefined,
): void => {
transporter.sendMail({
to,
subject,
text,
html,
from: from || process.env.MAILER_FROM,
}, function (err, info) {
if (process.env.NODE_ENV === 'development') {
const streamInfo = info as StreamTransport.SentMessageInfo;
if (streamInfo.message) {
(streamInfo.message as Readable).pipe(process.stdout);
}
}
if (err) {
Sentry.captureException(err);
}
});
};
const findTranslation = (key: string) => {
return _findTranslation(translations, key) || _findTranslation(fallbackTranslations, key);
};
const _findTranslation = (translations: Translations, key: string) => {
let x = translations;
for (const part of key.split('.')) {
x = x[part];
if (x === undefined) {
return undefined;
}
}
return x;
};
const violations = findTranslation('terms.content.content.violationsExamples');
delete violations.miscellaneous;
const terms = `${findTranslation('terms.content.content.violations')} ${Object.values(violations).join(', ')}`;
interface Template {
subject: string;
text: string;
html: string;
from?: string;
}
const templates: Record<string, Template> = {
base: {
subject: '[[title]] » {{content}}',
text: '',
html: `
<div style="margin: 36px auto; width: 100%; max-width: {{maxwidth}}; border: 1px solid #aaa;border-radius: 8px;overflow: hidden;font-family: Nunito, Quicksand, Helvetica, sans-serif;font-size: 16px;box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15)">
<div style="padding: 16px; padding-top: 10px; background: #f8f8f8; border-bottom: 1px solid #aaa;font-size: 20px;color: ${color};">
<img src="${logoEncoded}" style="height: 24px;width: 24px; position: relative; top: 6px; margin-right: 6px;" alt="Logo"/>
[[title]]
</div>
<div style="padding: 8px 16px; background: #fff;">
{{content}}
</div>
</div>
`,
},
notify: {
subject: 'There are entries awaiting moderation',
text: 'Entries awaiting moderation:\n\n{{list:stats}}',
html: `
<p>Entries awaiting moderation</p>
<ul>{{list:stats}}</ul>
`,
},
confirmCode: {
subject: '[[user.login.email.subject]]',
text: '[[user.login.email.instruction]]\n\n{{code}}\n\n[[user.login.email.extra]]',
html: `
<p>[[user.login.email.instruction]]</p>
<p style="border: 1px solid #aaa;border-radius: 8px;overflow: hidden;text-align: center;user-select: all;font-size: 24px; padding:8px;letter-spacing: 8px; font-weight: bold;">{{code}}</p>
<p style="font-size: 12px; color: #777">[[user.login.email.extra]]</p>
`,
},
ban: {
subject: '[[ban.header]]',
text: `[[ban.header]]\n\n[[ban.reason]][[quotation.colon]] %reason%\n\n[[quotation.start]]${terms}[[quotation.end]]`,
html: `
<p>[[ban.header]]</p>
<p>[[ban.reason]][[quotation.colon]] %reason%</p>
<p style="font-size: 12px; color: #777">[[quotation.start]]${terms}[[quotation.end]]</p>
<p style="font-size: 14px;">[[ban.appealEmail]]</p>
<p style="color: #999; font-size: 10px;">@{{username}}</p>
`,
from: process.env.MAILER_FROM_MODERATION,
},
inactivityWarning: {
subject: '[[user.removeInactive.email.subject]]',
text: '[[user.removeInactive.email.content]]',
html: `
<p>[[user.removeInactive.email.content]]</p>
<p>[[user.removeInactive.email.clarification]] <strong>@{{username}}</strong></p>
<p style="text-align: center; padding-top: 16px; padding-bottom: 16px;">
<a href="https://en.pronouns.page/account" target="_blank" rel="noopener" style="background-color: #C71585; color: #fff; padding: 8px 16px; border: none; border-radius: 6px;text-decoration: none">
[[user.removeInactive.email.cta]]
</a>
</p>
<p style="color: #999; font-size: 10px;">@{{username}}</p>
`,
},
cardsWarning: {
subject: 'Cards queue is getting long',
text: 'There\'s {{count}} cards in the queue!',
html: '<p>There\'s {{count}} cards in the queue!</p>',
from: process.env.MAILER_FROM_TECHNICAL,
},
linksWarning: {
subject: 'Links queue is getting long',
text: 'There\'s {{count}} links in the queue!',
html: '<p>There\'s {{count}} links in the queue!</p>',
from: process.env.MAILER_FROM_TECHNICAL,
},
translationProposed: {
subject: '[{{locale}}] New translations proposed',
text: 'Check them out here: https://[[domain]]/admin',
html: '<p>Check them out here: <a href="https://[[domain]]/admin/translations/awaiting" target="_blank" rel="noopener">[[domain]]/admin/translations/awaiting</a></p>',
from: process.env.MAILER_FROM_LOCALISATION,
},
translationToMerge: {
subject: '[{{locale}}] New translations ready to be merged',
text: 'Check them out here: https://[[domain]]/admin',
html: '<p>Check them out here: <a href="https://[[domain]]/admin/translations/awaiting" target="_blank" rel="noopener">[[domain]]/admin/translations/awaiting</a></p>',
from: process.env.MAILER_FROM_LOCALISATION,
},
modMessage: {
subject: '[[user.modMessage.subject]]',
text: '[[user.modMessage.intro]]\n\n{{nl2br:message}}\n\n[[user.modMessage.respond]]',
html: `
<p>[[user.modMessage.intro]]</p>
<p style="color: #222; padding-left: 1em;padding-right: 1em;font-style: italic;">{{nl2br:message}}</p>
<p>[[user.modMessage.respond]]</p>
<p style="color: #999; font-size: 10px;">@{{modUsername}} → @{{username}}</p>
`,
from: process.env.MAILER_FROM_MODERATION,
},
sensitiveApplied: {
subject: '[[profile.sensitive.email.subject]]',
text: '[[profile.sensitive.email.content]]\n\n{{warnings}}',
html: `
<p>[[profile.sensitive.email.content]] <strong>{{warnings}}</strong></p>
<p style="color: #999; font-size: 10px;">@{{modUsername}} → @{{username}}</p>
`,
from: process.env.MAILER_FROM_MODERATION,
},
miastamaszerujace: {
subject: 'miastamaszerujace.pl zmiana treści',
text: '',
html: `
<div style="display: flex;">
<div style="width: 50%; overflow-x: scroll">
<p>przed:</p>
<pre><code>{{before}}</code></pre>
</div>
<div style="width: 50%; overflow-x: scroll">
<p>po:</p>
<pre><code>{{after}}</code></pre>
</div>
</div>
`,
},
};
const applyTemplate = (
template: Template,
context: 'subject' | 'text' | 'html',
params: Record<string, string | string[] | Record<string, string>>,
): string => {
if (!params.maxwidth) {
params.maxwidth = '480px';
}
let templateText = template[context];
if (templates.base[context] !== undefined) {
templateText = templates.base[context].replace('{{content}}', templateText);
}
templateText = templateText.replace(/%reason%/g, '{{reason}}'); // TODO
templateText = templateText.replace(/\[\[([^\]]+)]]/g, (m) => {
return findTranslation(m.substring(2, m.length - 2));
});
templateText = templateText.replace(/{{([^}]+)}}/g, (m) => {
const key = m.substring(2, m.length - 2);
if (key.startsWith('list:')) {
const value = params[key.substring(5)] as string[] | Record<string, string>;
if (Array.isArray(value)) {
return context === 'html'
? value.map((s) => `<li>${s}</li>`).join('')
: value.map((s) => ` - ${s}`).join('\n');
} else {
return context === 'html'
? Object.keys(value).map((s) => `<li><strong>${s}:</strong> ${value[s]}</li>`)
.join('')
: Object.keys(value).map((s) => ` - ${s}: ${value[s]}`)
.join('\n');
}
}
if (key.startsWith('nl2br:')) {
const value = params[key.substring(6)] as string;
return context === 'html'
? value.replace(new RegExp('\\n', 'g'), '<br/>')
: value;
}
return params[key] as string;
});
return templateText;
};
export default (to: string, template: string | Template, params = {}): void => {
if (typeof template === 'string') {
template = templates[template];
}
sendEmail(
process.env.MAILER_OVERWRITE || to,
applyTemplate(template, 'subject', params),
applyTemplate(template, 'text', params),
applyTemplate(template, 'html', params),
template.from,
);
};