mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
443 lines
18 KiB
443 lines
18 KiB
import { createTransport } from 'nodemailer'
|
|
import type { Transporter } from 'nodemailer'
|
|
import type { Translator } from './locales.ts'
|
|
|
|
/**
|
|
* The templates this wiki sends, and what each one needs.
|
|
*
|
|
* Two of them belong to a flow — registration and a forgotten password; `test` is the admin area's
|
|
* button. Held as literals rather than rows in a table because nothing sends a mail this wiki did
|
|
* not ask it to — a template is part of the flow that uses it, and a flow that gained one would
|
|
* have to gain code here anyway.
|
|
*
|
|
* **What each one SAYS is not here**: every string lives in `locales/en.json` under `mail.*` and is
|
|
* translated with the rest of the interface, so adding a template means adding its keys there. See
|
|
* `render`.
|
|
*/
|
|
export interface MailTemplateData {
|
|
welcome: {
|
|
/** Who the account was created for, as they typed it. */
|
|
name: string
|
|
/** Where the site the account was created on lives, without a trailing slash. */
|
|
baseUrl: string
|
|
/**
|
|
* Where to go to confirm the address, when it has to be confirmed at all. Absent on a site whose
|
|
* local strategy does not validate addresses, where the account is usable as soon as it is made.
|
|
*
|
|
* A page that asks, not a link that acts: fetching it confirms nothing, which is what keeps the
|
|
* mail scanners that follow every link in a message from spending the token before the reader
|
|
* does.
|
|
*/
|
|
verifyUrl?: string
|
|
}
|
|
resetPwd: {
|
|
name: string
|
|
baseUrl: string
|
|
/** Where to choose the new password. Stands for the request until it is used or expires. */
|
|
resetUrl: string
|
|
}
|
|
test: {
|
|
baseUrl: string
|
|
}
|
|
}
|
|
|
|
/** A template key, i.e. one of the keys of `MailTemplateData`. */
|
|
export type MailTemplate = keyof MailTemplateData
|
|
|
|
/**
|
|
* One mail as its template describes it, before either body exists.
|
|
*
|
|
* Every mail this wiki sends is the same shape — a heading, some paragraphs, at most one thing to
|
|
* press — so a template says what goes in those slots and nothing about how they are drawn. Which
|
|
* is what lets the HTML body and the text body be two renderings of one description rather than
|
|
* two hand-written copies that drift: the pair of them used to be written out per template, and a
|
|
* string changed in one was a string not changed in the other.
|
|
*/
|
|
interface MailContent {
|
|
subject: string
|
|
/** The heading, which is the subject without the site's name repeated in it. */
|
|
title: string
|
|
/** Paragraphs, as plain text: escaping is the business of whichever body they end up in. */
|
|
body: string[]
|
|
action?: { label: string; url: string }
|
|
footer: string
|
|
}
|
|
|
|
/**
|
|
* The SMTP settings, as they are stored under the `mail` key of the settings table.
|
|
*
|
|
* Everything here is what an administrator typed in the admin area's Mail page, which is also the
|
|
* only thing that writes it — see `api/mail.ts`.
|
|
*/
|
|
interface MailConfig {
|
|
senderName?: string
|
|
senderEmail?: string
|
|
defaultBaseURL?: string
|
|
host?: string
|
|
port?: number
|
|
name?: string
|
|
secure?: boolean
|
|
verifySSL?: boolean
|
|
user?: string
|
|
pass?: string
|
|
useDKIM?: boolean
|
|
dkimDomainName?: string
|
|
dkimKeySelector?: string
|
|
dkimPrivateKey?: string
|
|
}
|
|
|
|
/** One outgoing mail, as the models ask for it. */
|
|
export interface MailRequest<K extends MailTemplate = MailTemplate> {
|
|
/** The site the mail is about, which is what names the wiki in it. */
|
|
siteId: string
|
|
to: string
|
|
template: K
|
|
data: MailTemplateData[K]
|
|
/**
|
|
* What language to write it in, when anything is known about the recipient's.
|
|
*
|
|
* The caller's job rather than this model's, because what is known differs at every send site and
|
|
* none of it is reachable from here: an account's own `prefs.locale`, the locale the browser
|
|
* making the request was reading the wiki in, or nothing at all for a mail nobody asked for. A
|
|
* locale that is not installed is ignored, so a value straight off a request body is safe to pass.
|
|
*
|
|
* Left empty, the mail is written in the site's primary locale — see `localeFor`.
|
|
*/
|
|
locale?: string | null
|
|
}
|
|
|
|
/**
|
|
* Take a value out of the template language it is being put into.
|
|
*
|
|
* Every substitution below is a name somebody typed or a URL built from a hostname, so all of it goes
|
|
* through here on the way into the HTML body. The text body needs none of it.
|
|
*/
|
|
function escapeHtml(str: string): string {
|
|
return str
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
}
|
|
|
|
/**
|
|
* The HTML body every mail shares: a heading, some paragraphs, at most one button.
|
|
*
|
|
* Written as a table with inline styles and no external anything, which is what a mail client will
|
|
* actually render — the stylesheet, the web font and the background image a page would use are all
|
|
* either stripped or blocked by the ones people read mail in.
|
|
*
|
|
* **The direction is declared three times on purpose.** Gmail and Outlook.com drop the `<html>` and
|
|
* `<body>` elements and paste what is between them into their own document, taking any `dir` on
|
|
* them with it — so a right-to-left mail read there would come out left-aligned, with its
|
|
* punctuation at the wrong end, unless the cell that survives carries the direction itself.
|
|
*/
|
|
function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean): string {
|
|
const dir = isRTL ? 'rtl' : 'ltr'
|
|
const align = isRTL ? 'right' : 'left'
|
|
const paragraphs = body
|
|
.map(
|
|
(p) =>
|
|
`<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#37474f;">${escapeHtml(p)}</p>`
|
|
)
|
|
.join('')
|
|
const button = action
|
|
? `<p style="margin:0 0 16px;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:12px 24px;border-radius:4px;background:#1976d2;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;">${escapeHtml(action.label)}</a></p>` +
|
|
// -> The same link in full, for the client that will not render the button and for the reader
|
|
// who wants to see where it goes before following it. Always left to right: a URL is not
|
|
// written in the language around it, and bidi reordering makes one unreadable.
|
|
`<p dir="ltr" style="margin:0 0 16px;font-size:12px;line-height:1.6;color:#78909c;word-break:break-all;text-align:${align};">${escapeHtml(action.url)}</p>`
|
|
: ''
|
|
return [
|
|
'<!DOCTYPE html>',
|
|
`<html dir="${dir}"><body dir="${dir}" style="margin:0;padding:24px;background:#eceff1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">`,
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:6px;">',
|
|
`<tr><td dir="${dir}" style="padding:32px;text-align:${align};">`,
|
|
`<h1 style="margin:0 0 24px;font-size:20px;line-height:1.4;color:#263238;">${escapeHtml(title)}</h1>`,
|
|
paragraphs,
|
|
button,
|
|
`<p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #eceff1;font-size:12px;line-height:1.6;color:#90a4ae;">${escapeHtml(footer)}</p>`,
|
|
'</td></tr></table></body></html>'
|
|
].join('')
|
|
}
|
|
|
|
/**
|
|
* The text body, which is the same description with nothing drawn around it.
|
|
*
|
|
* Every client that will not render HTML shows this one, and it is also what keeps a mail out of
|
|
* the spam folder a filter puts HTML-only messages in. Every field of the description appears,
|
|
* including the title: it is a heading in the HTML body and a first line here, and a paragraph
|
|
* written under one refers to it — "if you are reading it" has nothing to point at in a text body
|
|
* that opened with the sentence itself.
|
|
*
|
|
* The action's URL goes after the paragraphs rather than beside whichever sentence introduces it,
|
|
* so that the two bodies say things in the same order — the button sits after the paragraphs in
|
|
* the HTML one for the same reason.
|
|
*/
|
|
function textBody({ title, body, action, footer }: MailContent): string {
|
|
return [title, ...body, ...(action ? [action.url] : []), footer].join('\n\n')
|
|
}
|
|
|
|
/**
|
|
* Mail model
|
|
*
|
|
* The one way anything in this wiki sends an email, and the only place nodemailer is used. Three
|
|
* flows need it — confirming an address at registration, resetting a forgotten password, and the
|
|
* admin area's test button — and all three go through `send()`.
|
|
*
|
|
* **A wiki with no SMTP settings is the normal case.** Plenty of instances never configure one, so
|
|
* nothing here throws on its own: `isConfigured` is what a caller asks first, and what decides
|
|
* whether a flow that needs mail is offered at all. `send()` refuses with `ERR_MAIL_NOT_CONFIGURED`
|
|
* rather than failing obscurely inside the transport, so a flow that got that far says something
|
|
* an administrator can act on.
|
|
*
|
|
* The transport is built once and kept, and rebuilt when the settings behind it change —
|
|
* `configFingerprint()` is how that is noticed, rather than an event, because the settings can be
|
|
* changed on another instance in an HA set and this one would never hear about it.
|
|
*
|
|
* **Nothing here is written in English.** Every string comes out of `locales/en.json` under `mail.*`
|
|
* through `locales.translator`, which is the same string set and the same CrowdIn pipeline the
|
|
* interface uses — so a locale somebody translates arrives in the mails as well, and a template
|
|
* added here is a set of keys added there. Which language one mail is written in is `localeFor`.
|
|
*/
|
|
class Mail {
|
|
private transporter: Transporter | null = null
|
|
private fingerprint = ''
|
|
|
|
/** The stored settings, whatever state they are in. */
|
|
private get config(): MailConfig {
|
|
return (WIKI.config.mail ?? {}) as MailConfig
|
|
}
|
|
|
|
/**
|
|
* Whether mail can be sent at all.
|
|
*
|
|
* A host and a sender address, which are the two things no default can stand in for. Everything
|
|
* else has one: a port, whether to use TLS, and credentials that plenty of relays do not want.
|
|
*/
|
|
get isConfigured(): boolean {
|
|
const conf = this.config
|
|
return Boolean(conf.host?.trim() && conf.senderEmail?.trim())
|
|
}
|
|
|
|
/**
|
|
* What the current transport was built from. A change here is what invalidates it.
|
|
*/
|
|
private configFingerprint(): string {
|
|
const conf = this.config
|
|
return JSON.stringify([
|
|
conf.host,
|
|
conf.port,
|
|
conf.name,
|
|
conf.secure,
|
|
conf.verifySSL,
|
|
conf.user,
|
|
conf.pass,
|
|
conf.useDKIM,
|
|
conf.dkimDomainName,
|
|
conf.dkimKeySelector,
|
|
conf.dkimPrivateKey
|
|
])
|
|
}
|
|
|
|
private getTransporter(): Transporter {
|
|
const fingerprint = this.configFingerprint()
|
|
if (this.transporter && fingerprint === this.fingerprint) {
|
|
return this.transporter
|
|
}
|
|
this.transporter?.close?.()
|
|
const conf = this.config
|
|
this.transporter = createTransport({
|
|
host: conf.host,
|
|
port: conf.port ?? 465,
|
|
secure: conf.secure ?? true,
|
|
// -> The name this client identifies itself as in EHLO. Left off, nodemailer sends the machine
|
|
// hostname, which is what most relays expect.
|
|
...(conf.name?.trim() && { name: conf.name.trim() }),
|
|
// -> No credentials at all rather than empty ones: a relay that authenticates by IP address
|
|
// refuses an empty AUTH instead of skipping it.
|
|
...(conf.user?.trim() && {
|
|
auth: {
|
|
user: conf.user.trim(),
|
|
pass: conf.pass ?? ''
|
|
}
|
|
}),
|
|
tls: {
|
|
rejectUnauthorized: conf.verifySSL !== false
|
|
},
|
|
...(conf.useDKIM &&
|
|
conf.dkimPrivateKey?.trim() && {
|
|
dkim: {
|
|
domainName: conf.dkimDomainName ?? '',
|
|
keySelector: conf.dkimKeySelector ?? '',
|
|
privateKey: conf.dkimPrivateKey
|
|
}
|
|
})
|
|
})
|
|
this.fingerprint = fingerprint
|
|
return this.transporter
|
|
}
|
|
|
|
/**
|
|
* Where links in emails point, without a trailing slash.
|
|
*
|
|
* Three answers, in the order they are preferred:
|
|
*
|
|
* 1. The configured base URL, which is the only one an administrator has actually vouched for. An
|
|
* instance behind a proxy, on a private address, or answering to several hostnames cannot be
|
|
* trusted to describe itself to somebody reading a mail somewhere else.
|
|
* 2. The site's own hostname, which is what the account is on — and which is not necessarily the
|
|
* host the request came in on: an administrator creating an account for another site is doing
|
|
* exactly that. Skipped for the wildcard site, which names no host.
|
|
* 3. What the request was addressed to, which is right often enough that a small instance never has
|
|
* to configure anything.
|
|
*
|
|
* @param req The request that triggered the mail, when there is one
|
|
* @param siteId The site the mail is about, when it is about one
|
|
*/
|
|
baseUrl({
|
|
req,
|
|
siteId
|
|
}: { req?: { protocol: string; host: string }; siteId?: string } = {}): string {
|
|
const configured = this.config.defaultBaseURL?.trim()
|
|
if (configured) {
|
|
return configured.replace(/\/+$/, '')
|
|
}
|
|
const hostname = siteId ? WIKI.sites[siteId]?.hostname : null
|
|
if (hostname && hostname !== '*') {
|
|
// -> The scheme the caller was reached by, since the hostname alone does not carry one
|
|
return `${req?.protocol ?? 'https'}://${hostname}`
|
|
}
|
|
if (req) {
|
|
return `${req.protocol}://${req.host}`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* What to call this wiki in a mail. Per site, since that is what the reader was looking at.
|
|
*/
|
|
private siteName(siteId: string): string {
|
|
return WIKI.sites[siteId]?.config?.title || 'Wiki.js'
|
|
}
|
|
|
|
/**
|
|
* The language a mail is written in.
|
|
*
|
|
* What the caller knows about the recipient, and the site's primary locale when it knows nothing
|
|
* — which is the wiki's own language, and the right answer for a mail about a site rather than
|
|
* one addressed to a reader with a preference. `translator` takes it from there: a code naming a
|
|
* locale that is not installed falls back to English rather than sending a mail full of keys.
|
|
*/
|
|
private localeFor(locale: string | null | undefined, siteId: string): string | null {
|
|
return locale || WIKI.sites[siteId]?.config?.locales?.primary || null
|
|
}
|
|
|
|
/**
|
|
* Describe one of the templates in the locale it is being sent in.
|
|
*
|
|
* Strings come from `locales/en.json` under `mail.*` and are translated with the rest of the
|
|
* interface, so what is left here is which keys a template uses and what it puts in them. Both
|
|
* bodies are rendered from the one description that comes out — see `MailContent`.
|
|
*/
|
|
private render<K extends MailTemplate>(
|
|
{ t }: Translator,
|
|
siteName: string,
|
|
template: K,
|
|
data: MailTemplateData[K]
|
|
): MailContent {
|
|
switch (template) {
|
|
case 'welcome': {
|
|
const d = data as MailTemplateData['welcome']
|
|
if (d.verifyUrl) {
|
|
return {
|
|
subject: t('mail.welcome.verify.subject', { siteName }),
|
|
title: t('mail.welcome.verify.title'),
|
|
body: [
|
|
t('mail.common.greeting', { name: d.name }),
|
|
t('mail.welcome.verify.body', { siteName }),
|
|
t('mail.welcome.verify.expiry')
|
|
],
|
|
action: { label: t('mail.welcome.verify.action'), url: d.verifyUrl },
|
|
footer: t('mail.welcome.footer', { siteName })
|
|
}
|
|
}
|
|
return {
|
|
subject: t('mail.welcome.subject', { siteName }),
|
|
title: t('mail.welcome.subject', { siteName }),
|
|
body: [t('mail.common.greeting', { name: d.name }), t('mail.welcome.body', { siteName })],
|
|
action: { label: t('mail.welcome.action'), url: `${d.baseUrl}/login` },
|
|
footer: t('mail.welcome.footer', { siteName })
|
|
}
|
|
}
|
|
case 'resetPwd': {
|
|
const d = data as MailTemplateData['resetPwd']
|
|
return {
|
|
subject: t('mail.resetPwd.subject', { siteName }),
|
|
title: t('mail.resetPwd.title'),
|
|
body: [
|
|
t('mail.common.greeting', { name: d.name }),
|
|
t('mail.resetPwd.body', { siteName }),
|
|
t('mail.resetPwd.expiry')
|
|
],
|
|
action: { label: t('mail.resetPwd.action'), url: d.resetUrl },
|
|
footer: t('mail.resetPwd.footer', { siteName })
|
|
}
|
|
}
|
|
default: {
|
|
const d = data as MailTemplateData['test']
|
|
return {
|
|
subject: t('mail.test.subject', { siteName }),
|
|
title: t('mail.test.title'),
|
|
body: [t('mail.test.body', { siteName })],
|
|
action: { label: t('mail.test.action'), url: d.baseUrl },
|
|
footer: t('mail.test.footer')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send one mail, and wait for the relay to have taken it.
|
|
*
|
|
* Waiting is deliberate: every caller has something to tell the user about the result — a
|
|
* registration that says to go and check, a reset that says the same, a test button whose entire
|
|
* purpose is the answer — and a queued send would have to report success before it knew.
|
|
*
|
|
* @throws `ERR_MAIL_NOT_CONFIGURED` when there is no SMTP server to send through, and whatever
|
|
* nodemailer raises for a send that was attempted and failed
|
|
*/
|
|
async send<K extends MailTemplate>({
|
|
siteId,
|
|
to,
|
|
template,
|
|
data,
|
|
locale
|
|
}: MailRequest<K>): Promise<void> {
|
|
if (!this.isConfigured) {
|
|
throw new Error('ERR_MAIL_NOT_CONFIGURED')
|
|
}
|
|
const conf = this.config
|
|
const siteName = this.siteName(siteId)
|
|
const translator = await WIKI.models.locales.translator(this.localeFor(locale, siteId))
|
|
const content = this.render(translator, siteName, template, data)
|
|
const { subject } = content
|
|
const text = textBody(content)
|
|
const html = htmlShell(content, translator.isRTL)
|
|
WIKI.logger.debug(`Sending ${template} email to <${to}> in ${translator.locale}...`)
|
|
await this.getTransporter().sendMail({
|
|
from: {
|
|
name: conf.senderName?.trim() || siteName,
|
|
address: conf.senderEmail!.trim()
|
|
},
|
|
to,
|
|
subject,
|
|
text,
|
|
html
|
|
})
|
|
WIKI.logger.info(`Sent ${template} email to <${to}>.`)
|
|
}
|
|
}
|
|
|
|
export const mail = new Mail()
|