Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
372 lines
16 KiB
JavaScript
372 lines
16 KiB
JavaScript
// ── Outbound mail ──────────────────────────────────────────────────────────
|
|
//
|
|
// Email is configured in Admin → Settings → Email, not via env vars. The
|
|
// `email_config` singleton holds the enabled flag, the sender identity and the
|
|
// AES-GCM-encrypted credential for whichever transport is selected; the transport
|
|
// itself is a registration in `src/engagement/transports` (ENGAGEMENT.md §3.1),
|
|
// so which provider is used is DATA, not a code path in this file.
|
|
//
|
|
// Gmail OAuth2 was removed in engagement Phase 1 (§1.2a). Gmail is still reachable
|
|
// as an ordinary SMTP relay (`smtp.gmail.com:587` with an app password) — the
|
|
// operator types those in like any other host; nothing in here knows about it.
|
|
//
|
|
// **The failure contracts are the point of this file.** Six call sites, five
|
|
// senders, and each one degrades a specific way when mail is unconfigured. Those
|
|
// contracts are unchanged by the transport rewrite and are asserted in
|
|
// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite
|
|
// and sendPasswordReset return { sent: false, reason: 'NOT_CONFIGURED' } so their
|
|
// callers can surface a link / answer a generic 200, sendTeamNotification never
|
|
// throws at all, and only sendTest throws — because only sendTest has an admin
|
|
// waiting to be told why.
|
|
|
|
const emailConfig = require('../model/emailConfig/emailConfig.model')
|
|
const settings = require('../model/settings/settings.model')
|
|
const { transports } = require('../engagement')
|
|
const brand = require('../config/brand')
|
|
const log = require('./logger')('mailer')
|
|
|
|
// Ready to send only when enabled, holding a complete credential for a
|
|
// registered transport, and knowing which address to send as.
|
|
async function isConfigured() {
|
|
const c = await emailConfig.getSafe()
|
|
return Boolean(c.enabled && c.hasCredential && c.senderEmail)
|
|
}
|
|
|
|
// Recipient for the contact form: the admin-editable contact_email setting, or
|
|
// the configured sending address as a last resort.
|
|
async function contactRecipient(senderEmail) {
|
|
const to = await settings.get('contact_email')
|
|
return to || senderEmail || null
|
|
}
|
|
|
|
/**
|
|
* Build a transport from the stored config. Returns { transport, config } or
|
|
* null when unconfigured — every sender handles null itself.
|
|
*
|
|
* Null, never a throw, for all five ways this can fail: no row, disabled, no
|
|
* sender address, an incomplete credential, or a stored transport id that is not
|
|
* registered (a downgrade, or a provider removed from the build). The last one is
|
|
* the reason `transports.get()` is checked rather than assumed: a send-time
|
|
* exception from an unknown id would break the contact form for a reason the
|
|
* admin screen already shows.
|
|
*
|
|
* **`enabled` is checked here now, and it was not before.** Under the connect
|
|
* flow this function tested only "is there a refresh token and a sender", so the
|
|
* contact form kept sending after an admin unticked "Enable email sending" —
|
|
* `isConfigured()` honoured the toggle but the direct senders bypassed it. With
|
|
* the toggle no longer set as a side effect of a consent redirect it has to mean
|
|
* what it says, so the gate lives on the one path every sender shares.
|
|
*/
|
|
async function buildTransport() {
|
|
const config = await emailConfig.getWithSecret()
|
|
if (!config || !config.enabled || !config.senderEmail) return null
|
|
const def = transports.get(config.transport)
|
|
if (!def) {
|
|
log.warn('email send skipped: no such transport', { transport: config.transport })
|
|
return null
|
|
}
|
|
if (!transports.isComplete(config.transport, config.credentialSecret)) {
|
|
log.warn('email send skipped: transport credentials are incomplete', { transport: config.transport })
|
|
return null
|
|
}
|
|
try {
|
|
return { transport: def.build(config.credentialSecret, config), config }
|
|
} catch (err) {
|
|
log.error('could not build the mail transport', err)
|
|
return null
|
|
}
|
|
}
|
|
|
|
function fromHeader(config) {
|
|
return config.senderName ? `"${config.senderName}" <${config.senderEmail}>` : config.senderEmail
|
|
}
|
|
|
|
// Reply-To is the operator's optional override; the contact form's per-message
|
|
// replyTo (the visitor's address) wins over it, which is the whole reason the
|
|
// contact form has one.
|
|
function replyToFor(config, override) {
|
|
return override || config.replyTo || undefined
|
|
}
|
|
|
|
/**
|
|
* Send a contact message. If email is not configured/enabled, signals the caller
|
|
* to fall back to a mailto: link instead of throwing.
|
|
*/
|
|
async function sendContactMessage({ name, email, message }) {
|
|
const built = await buildTransport()
|
|
if (!built) {
|
|
const c = await emailConfig.getSafe()
|
|
return { sent: false, fallback: 'mailto', email: await contactRecipient(c.senderEmail) }
|
|
}
|
|
const { transport, config } = built
|
|
const to = await contactRecipient(config.senderEmail)
|
|
try {
|
|
await transport.sendMail({
|
|
from: fromHeader(config),
|
|
to,
|
|
replyTo: replyToFor(config, email),
|
|
subject: `${brand.name} contact from ${name || 'a visitor'}`,
|
|
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
|
|
})
|
|
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
|
|
return { sent: true }
|
|
} catch (err) {
|
|
log.error('contact send failed', err)
|
|
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err) })
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turn a transport error into something an operator can act on.
|
|
*
|
|
* This matters more than it used to. Under Gmail OAuth2 the sending address came
|
|
* back from Google's userinfo and was guaranteed to be a mailbox the credential
|
|
* owned. Under SMTP `sender_email` is operator-typed, so a relay rejecting the
|
|
* envelope From is now a live failure mode (§1.2a consequence 2) — and it arrives
|
|
* as a bare "550 5.7.1" that means nothing without the sender in front of it.
|
|
*/
|
|
function describeSendError(err, config) {
|
|
const code = err && (err.responseCode || err.code)
|
|
const base = (err && (err.response || err.message)) || 'Send failed'
|
|
const sender = config && config.senderEmail
|
|
if (sender && (code === 550 || code === 553 || code === 554 || code === 'EENVELOPE')) {
|
|
return `${base} — the server refused "${sender}" as the sender. It must be an address this account is allowed to send as (SPF/DMARC).`
|
|
}
|
|
if (code === 'EAUTH') return `${base} — the username or password was rejected.`
|
|
if (code === 'ESOCKET' || code === 'ECONNECTION') {
|
|
return `${base} — could not connect. Check the host, the port, and whether "Implicit TLS" matches it (on for 465, off for 587).`
|
|
}
|
|
if (code === 'ETIMEDOUT') {
|
|
return `${base} — the connection timed out. A common cause is "Implicit TLS" left on for port 587.`
|
|
}
|
|
return String(base)
|
|
}
|
|
|
|
/**
|
|
* Send a test email to `to`, used by the admin "Send test" button. Throws on
|
|
* failure; records the outcome either way. Returns { sent: true } on success.
|
|
*
|
|
* **This is now the real verification of the whole configuration** — host, port,
|
|
* TLS mode, credentials AND whether the relay will accept the operator-typed
|
|
* sender. There is no consent flow left to prove any of it beforehand.
|
|
*/
|
|
async function sendTest(to) {
|
|
const built = await buildTransport()
|
|
if (!built) {
|
|
const err = new Error('Email is not configured. Set a transport, its credentials and a sender address first.')
|
|
err.code = 'NOT_CONFIGURED'
|
|
throw err
|
|
}
|
|
const { transport, config } = built
|
|
const recipient = to || (await contactRecipient(config.senderEmail))
|
|
if (!recipient) {
|
|
const err = new Error('No recipient available for the test email.')
|
|
err.code = 'NO_RECIPIENT'
|
|
throw err
|
|
}
|
|
try {
|
|
await transport.sendMail({
|
|
from: fromHeader(config),
|
|
to: recipient,
|
|
replyTo: replyToFor(config),
|
|
subject: `${brand.name} email test`,
|
|
text: `This is a test message confirming ${config.transport} email delivery is working.`,
|
|
})
|
|
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
|
|
return { sent: true, to: recipient }
|
|
} catch (err) {
|
|
const detail = describeSendError(err, config)
|
|
log.error('test send failed', err)
|
|
await emailConfig.recordStatus({ status: 'error', statusDetail: detail })
|
|
const wrapped = new Error(detail)
|
|
wrapped.code = err.code || 'SEND_FAILED'
|
|
throw wrapped
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an account invite. `to` is the invitee's email, `acceptUrl` the tokened
|
|
* accept link, `role` their assigned access level, `invitedByName` optional. If
|
|
* email is not configured, returns { sent: false, reason: 'NOT_CONFIGURED' } so
|
|
* the caller can surface the accept link for the admin to share manually rather
|
|
* than throwing. Throws only on an actual send failure.
|
|
*/
|
|
async function sendInvite({ to, acceptUrl, role, invitedByName }) {
|
|
const built = await buildTransport()
|
|
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
|
const { transport, config } = built
|
|
const roleLabel = role && role !== 'player' ? ` as ${role}` : ''
|
|
const by = invitedByName ? ` by ${invitedByName}` : ''
|
|
try {
|
|
await transport.sendMail({
|
|
from: fromHeader(config),
|
|
to,
|
|
replyTo: replyToFor(config),
|
|
subject: `Your ${brand.name} invitation`,
|
|
text:
|
|
`You have been invited${by} to join ${brand.name}${roleLabel}.\n\n` +
|
|
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
|
|
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
|
|
})
|
|
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() })
|
|
return { sent: true }
|
|
} catch (err) {
|
|
log.error('invite send failed', err)
|
|
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a password-reset link. `to` is the account's email, `resetUrl` the tokened
|
|
* reset link, `username` names which account it's for. If email is not configured, returns
|
|
* { sent: false, reason: 'NOT_CONFIGURED' } — the caller still returns a generic
|
|
* success to avoid leaking whether the address exists. Throws only on a send failure.
|
|
*/
|
|
async function sendPasswordReset({ to, resetUrl, username }) {
|
|
const built = await buildTransport()
|
|
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
|
const { transport, config } = built
|
|
const forWhom = username ? ` for the account “${username}”` : ''
|
|
try {
|
|
await transport.sendMail({
|
|
from: fromHeader(config),
|
|
to,
|
|
replyTo: replyToFor(config),
|
|
subject: `Reset your ${brand.name} password`,
|
|
text:
|
|
`We received a request to reset the password${forWhom} at ${brand.name}.\n\n` +
|
|
`Choose a new password here:\n${resetUrl}\n\n` +
|
|
`This link is single-use and expires in about an hour. If you didn't request ` +
|
|
`this, you can safely ignore this email — your password won't change.`,
|
|
})
|
|
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Password reset send OK', lastVerifiedAt: new Date() })
|
|
return { sent: true }
|
|
} catch (err) {
|
|
log.error('password reset send failed', err)
|
|
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an email-verification link (engagement Phase 1b). `to` is the address
|
|
* being PROVED — which is by definition not yet the account's address, and may
|
|
* belong to someone who has never heard of this site. So the copy names the
|
|
* account and says plainly what to do if it was not you, and the link installs
|
|
* an address rather than granting any access.
|
|
*
|
|
* Returns { sent: false, reason: 'NOT_CONFIGURED' } when mail is unconfigured;
|
|
* the caller surfaces that honestly, because unlike a password reset there is no
|
|
* enumeration reason to pretend a mail went out to an address the CALLER typed.
|
|
*/
|
|
async function sendEmailVerification({ to, verifyUrl, username }) {
|
|
const built = await buildTransport()
|
|
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
|
const { transport, config } = built
|
|
const forWhom = username ? ` “${username}”` : ''
|
|
try {
|
|
await transport.sendMail({
|
|
from: fromHeader(config),
|
|
to,
|
|
replyTo: replyToFor(config),
|
|
subject: `Confirm your email address for ${brand.name}`,
|
|
text:
|
|
`The ${brand.name} account${forWhom} asked to use this address for contact and account recovery.\n\n` +
|
|
`Confirm it here:\n${verifyUrl}\n\n` +
|
|
`This link is single-use and expires in about a day. Until it is used, nothing changes — ` +
|
|
`the account keeps whatever address it had.\n\n` +
|
|
`If you did not ask for this, you can ignore this email. Someone may have mistyped their ` +
|
|
`own address; no account of yours is affected and this link grants no access to anything.`,
|
|
})
|
|
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Verification send OK', lastVerifiedAt: new Date() })
|
|
return { sent: true }
|
|
} catch (err) {
|
|
log.error('email verification send failed', err)
|
|
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a Team notification — one event (`immediate` mode) or a day's worth
|
|
* (`digest` mode). TEAMS.md §6.4.
|
|
*
|
|
* **This one carries CONTENT, and the push tickle beside it deliberately does
|
|
* not.** A tickle goes to ntfy, an untrusted relay reachable by an unguessable
|
|
* topic, so it carries `{ stream, ref }` and the app pulls the real thing over an
|
|
* access-checked API. A mailbox is a destination the recipient chose. Same
|
|
* reasoning as the Discord bridge (§7.2), and it is why this function takes
|
|
* excerpts rather than ids.
|
|
*
|
|
* **Excerpts, never full posts.** Partly courtesy, mostly so that the blast radius
|
|
* of a mis-addressed or forwarded mail is a sentence rather than a thread. The
|
|
* caller does the truncation, because it is the caller that knows the body was
|
|
* already stripped of markup.
|
|
*
|
|
* The `List-Unsubscribe` pair is what makes a mail client's own unsubscribe button
|
|
* appear, and both halves are needed: the `mailto:`-free URL form for clients that
|
|
* open the link, and `List-Unsubscribe-Post` for RFC 8058 one-click, which POSTs
|
|
* without ever showing the user a page. Both reach the same tokened endpoint that
|
|
* writes the same per-Team mute the site shows.
|
|
*
|
|
* Never throws. A notification failing must not fail the forum write that caused
|
|
* it, and there is nobody up the stack to catch it — the digest worker runs on a
|
|
* timer and the immediate send is fired from a request that has already replied.
|
|
*/
|
|
async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubscribeUrl, unsubscribeApiUrl }) {
|
|
const built = await buildTransport()
|
|
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
|
const { transport, config } = built
|
|
|
|
const lines = [intro, '']
|
|
for (const item of items || []) {
|
|
lines.push(`${item.heading}`)
|
|
if (item.excerpt) lines.push(` ${item.excerpt}`)
|
|
if (item.url) lines.push(` ${item.url}`)
|
|
lines.push('')
|
|
}
|
|
if (teamUrl) lines.push(teamUrl, '')
|
|
if (unsubscribeUrl) {
|
|
lines.push('To stop these emails for this team, use this link:', unsubscribeUrl)
|
|
}
|
|
|
|
try {
|
|
await transport.sendMail({
|
|
from: fromHeader(config),
|
|
to,
|
|
replyTo: replyToFor(config),
|
|
subject,
|
|
text: lines.join('\n'),
|
|
// The header carries the API url, not the one in the body: a one-click
|
|
// client POSTs to whatever is here without rendering anything, so it has to
|
|
// be an endpoint. Falls back to the body's url when no API one was passed.
|
|
headers: (unsubscribeApiUrl || unsubscribeUrl)
|
|
? {
|
|
'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`,
|
|
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
|
}
|
|
: undefined,
|
|
})
|
|
return { sent: true }
|
|
} catch (err) {
|
|
// Logged and swallowed, unlike every other sender in this file. Those are
|
|
// called by a request that can report the failure to whoever caused it; this
|
|
// one is not, and recordStatus already puts the error where an admin reads it.
|
|
log.warn('team notification send failed', { message: err.message })
|
|
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }).catch(() => {})
|
|
return { sent: false, reason: 'SEND_FAILED' }
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
isConfigured,
|
|
sendContactMessage,
|
|
sendTest,
|
|
sendInvite,
|
|
sendPasswordReset,
|
|
sendEmailVerification,
|
|
sendTeamNotification,
|
|
}
|