mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-08-03 11:07:00 -04:00
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
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,
|
||
);
|
||
};
|