PronounsPage/server/mailer.ts
Valentyne Stigloher 4f7ccab2c6 (nuxt) use --env-file to read .env before nitro reads process.env, make dotenv.ts an import with side effects that only sets additional variables
care must be taken because everything initialized later will not be picked up by Nitro useRuntimeConfig() without passing an event
2025-03-14 22:18:09 +01:00

247 lines
9.9 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 { 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;
}
};
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',
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,
);
};