Files
website/server/src/utils/mailer.js
Claude 91c206bf76 feat(provisioning): game-account signup, admin email invites, unlink (2.0)
Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:50:49 -05:00

158 lines
6.1 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 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: `UOMysticmoon 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: 'UOMysticmoon 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 UOMysticmoon invitation',
text:
`You have been invited${by} to join UOMysticmoon${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
}
}
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite }