A web-only user on a deployment running neither the Android app nor Discord gets no notification that someone replied to their own thread — which is most users on most deployments, and a forum where replies are invisible is a forum nobody returns to. Email is a third consumer of the recipient set the previous commit builds, not a fourth pipeline. Unlike a push tickle, an email carries content: a mailbox is a destination the recipient chose, not an untrusted relay reached by an unguessable topic. It carries a title and an excerpt, never a full post. The digest COMPUTES AT SEND TIME and keeps no pending-items queue. The only state is `last_digest_at`. Three properties fall out, and the third is why it was chosen: a deployment down for two days sends one correct digest rather than replaying a backlog; a post a moderator hid after it was written is simply not in the query; and a user who lost forum access between the post and the send is no longer in the recipient set, so they are not emailed content they can no longer read. `last_digest_at` is stamped only on a SUCCESSFUL send — stamping first would quietly eat a day of somebody's notifications every time the mail provider had a bad minute. One-click unsubscribe is a stateless HMAC rather than a token table. Every property that makes a password-reset token a row is absent: the link sits in a mailbox for months so it has no useful expiry, and clicking it twice must mean what clicking it once meant. Its whole capability is setting `muted` for one (user, Team) pair. Co-Authored-By: Claude <noreply@anthropic.com>
268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
// ── Outbound mail (Gmail over OAuth2 / SMTP XOAUTH2) ───────────────────────
|
|
//
|
|
// Email is configured in Admin → Settings → Email, not via env vars. The
|
|
// connection (enabled flag, connected Gmail address, encrypted refresh token)
|
|
// lives in the email_config singleton; the OAuth client id/secret are reused
|
|
// from the `google` auth_providers row. nodemailer takes the refresh token and
|
|
// auto-mints short-lived access tokens for each send.
|
|
//
|
|
// When email is not configured, sendContactMessage does NOT throw — it signals
|
|
// the caller to fall back to a mailto: link (the contact form relies on this).
|
|
|
|
const nodemailer = require('nodemailer')
|
|
|
|
const emailConfig = require('../model/emailConfig/emailConfig.model')
|
|
const authProviders = require('../model/authProviders/authProviders.model')
|
|
const settings = require('../model/settings/settings.model')
|
|
const brand = require('../config/brand')
|
|
const log = require('./logger')('mailer')
|
|
|
|
// Ready to send only when enabled, connected (has a refresh token), and we know
|
|
// which address to send as.
|
|
async function isConfigured() {
|
|
const c = await emailConfig.getSafe()
|
|
return Boolean(c.enabled && c.hasRefreshToken && c.senderEmail)
|
|
}
|
|
|
|
// Recipient for the contact form: the admin-editable contact_email setting, or
|
|
// the connected sending address as a last resort.
|
|
async function contactRecipient(senderEmail) {
|
|
const to = await settings.get('contact_email')
|
|
return to || senderEmail || null
|
|
}
|
|
|
|
// Build a nodemailer OAuth2 transport from the stored config + reused Google
|
|
// client credentials. Returns { transport, config } or null when unconfigured.
|
|
async function buildTransport() {
|
|
const config = await emailConfig.getWithSecret()
|
|
if (!config || !config.refreshToken || !config.senderEmail) return null
|
|
const google = await authProviders.getWithSecret('google')
|
|
if (!google || !google.client_id || !google.client_secret) {
|
|
log.warn('email send skipped: Google OAuth client is not configured')
|
|
return null
|
|
}
|
|
const transport = nodemailer.createTransport({
|
|
host: 'smtp.gmail.com',
|
|
port: 465,
|
|
secure: true,
|
|
auth: {
|
|
type: 'OAuth2',
|
|
user: config.senderEmail,
|
|
clientId: google.client_id,
|
|
clientSecret: google.client_secret,
|
|
refreshToken: config.refreshToken,
|
|
},
|
|
})
|
|
return { transport, config }
|
|
}
|
|
|
|
function fromHeader(config) {
|
|
return config.senderName ? `"${config.senderName}" <${config.senderEmail}>` : config.senderEmail
|
|
}
|
|
|
|
/**
|
|
* 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: 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: err.message })
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async function sendTest(to) {
|
|
const built = await buildTransport()
|
|
if (!built) {
|
|
const err = new Error('Email is not configured. Connect Gmail 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,
|
|
subject: `${brand.name} email test`,
|
|
text: 'This is a test message confirming Gmail OAuth2 email delivery is working.',
|
|
})
|
|
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
|
|
return { sent: true, to: recipient }
|
|
} catch (err) {
|
|
log.error('test send failed', err)
|
|
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
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: err.message })
|
|
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 (email is non-unique, so one
|
|
* address may receive a link per account). 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,
|
|
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: err.message })
|
|
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,
|
|
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: err.message }).catch(() => {})
|
|
return { sent: false, reason: 'SEND_FAILED' }
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
isConfigured,
|
|
sendContactMessage,
|
|
sendTest,
|
|
sendInvite,
|
|
sendPasswordReset,
|
|
sendTeamNotification,
|
|
}
|