PronounsPage/server/mailer.ts
Adaline Simonian 23a3862ca0
test: introduce snapshot-based smoke tests
- Adds a new test suite with Docker-based smoke tests for all locales.
  Can be run using the ./smoketest.sh script.
- Replaces all calls to Math.random() with a new helper that returns 0.5
  in snapshot testing mode, ensuring deterministic snapshots.
- Similarly replaces all calls to new Date() and Date.now() with new
  helpers that return a fixed date in snapshot testing mode.
- Replaces checks against NODE_ENV with APP_ENV, to ensure that the
  bundles can be built with Nuxt for testing without losing code that
  would otherwise be stripped out by production optimizations.
- Adds a database init script that can be used to initialize the
  database with a single admin user and a long-lived JWT token for use
  in automation tests.
- Adds a JWT decoding/encoding CLI tool for debugging JWTs.

Note: Snapshots are not checked in, and must be generated manually. See
test/__snapshots__/.gitignore for more information.
2025-02-02 23:11:19 -08:00

263 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 { env } from './env.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 && env === 'development') {
transport = { streamTransport: true };
}
const transporter = nodemailer.createTransport(transport);
const sendEmail = async (
to: string,
subject: string,
text: string | undefined = undefined,
html: string | undefined = undefined,
from: string | undefined = undefined,
): Promise<void> => {
try {
const info = await transporter.sendMail({
to,
subject,
text,
html,
from: from || process.env.MAILER_FROM,
});
if (env === 'development') {
const streamInfo = info as StreamTransport.SentMessageInfo;
if (streamInfo.message) {
(streamInfo.message as Readable).pipe(process.stdout);
}
}
} catch (err) {
Sentry.captureException(err);
throw 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 async (to: string, template: string | Template, params = {}): Promise<void> => {
if (typeof template === 'string') {
template = templates[template];
}
await sendEmail(
process.env.MAILER_OVERWRITE || to,
applyTemplate(template, 'subject', params),
applyTemplate(template, 'text', params),
applyTemplate(template, 'html', params),
template.from,
);
};