mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-24 05:05:20 -04:00

care must be taken because everything initialized later will not be picked up by Nitro useRuntimeConfig() without passing an event
247 lines
9.9 KiB
TypeScript
247 lines
9.9 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;
|
||
}
|
||
};
|
||
|
||
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,
|
||
);
|
||
};
|