PronounsPage/server/mailer.ts

302 lines
11 KiB
TypeScript
Raw 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 { env } from './env.ts';
import { rootDir } from './paths.ts';
import type { Translator } from '~/src/translator.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)}`;
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 processStyles = (styleDefs: Record<string, string>) => Object.entries(styleDefs)
.map(([key, value]) => `${key}: ${value};`).join(' ');
interface Template {
subject: string;
text: string;
html: string;
from?: string;
}
const templates: Record<string, Template> = {
base: {
subject: '[[title]] » {{content}}',
text: '',
html: `
<div style="${processStyles({
'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="${processStyles({
'padding': '16px',
'padding-top': '10px',
'background': '#f8f8f8',
'border-bottom': '1px solid #aaa',
'font-size': '20px',
color,
})}">
<img src="${logoEncoded}" style="${processStyles({
'height': '24px',
'width': '24px',
'position': 'ralative',
'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="${processStyles({
'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]]',
'[[ban.reason]][[quotation.colon]] %reason%',
'[[quotation.start]][[terms]][[quotation.end]]',
].join('\n\n'),
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="${processStyles({
'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',
translator: Translator,
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.replaceAll('[[terms]]', () => {
const violations = translator.get<Record<string, string>>('terms.content.content.violationsExamples');
const displayedViolations = Object.entries(violations).filter(([key, _]) => key !== 'miscellaneous')
.map(([_, value]) => value);
return `${translator.translate('terms.content.content.violations')} ${displayedViolations.join(', ')}`;
});
templateText = templateText.replace(/\[\[([^\]]+)]]/g, (m) => {
return translator.translate(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, translator: Translator, params = {}): Promise<void> => {
if (typeof template === 'string') {
template = templates[template];
}
await sendEmail(
process.env.MAILER_OVERWRITE || to,
applyTemplate(template, 'subject', translator, params),
applyTemplate(template, 'text', translator, params),
applyTemplate(template, 'html', translator, params),
template.from,
);
};