// ── 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, }