feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s

Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:07:39 -05:00
parent 1d7961e7a2
commit 12ff201ed5
22 changed files with 2222 additions and 154 deletions

View File

@@ -10,6 +10,11 @@
// 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.
//
// Engagement Phase 5a moved every subject and body out of this file. What each
// sender still owns is its RECIPIENT, its headers and its failure contract; what
// it says is an `engagement_templates` row rendered by `engagement/templates.js`
// (§4.6.1), which an operator can edit and which falls back to the shipped seed.
//
// **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
@@ -22,9 +27,39 @@
const emailConfig = require('../model/emailConfig/emailConfig.model')
const settings = require('../model/settings/settings.model')
const { transports } = require('../engagement')
const brand = require('../config/brand')
const templates = require('../engagement/templates')
const log = require('./logger')('mailer')
/**
* The body for one message, from its template (engagement Phase 5a).
*
* Every subject and body in this file used to be a template literal; they are now
* `engagement_templates` rows an operator can edit, and this is the single seam
* where that happens. Three properties the senders below depend on:
*
* - **It cannot fail.** `renderByKey` falls back to the shipped seed whenever the
* row is missing or unusable, so no failure mode of the templates table can
* stop a password-reset mail. It returns null only for a key that names neither
* a row nor a seed, which is a programmer error and throws here rather than
* sending a blank message.
* - **The text part is byte-identical to what this file used to build**, which is
* §5a's acceptance criterion and is pinned by `test/emailTemplates.test.js`.
* - **The HTML part is new.** Nothing here had one before; mail is now
* multipart/alternative, so a client that prefers HTML shows the branded body
* and one that does not shows exactly the text it always showed.
*/
async function body(key, values) {
const rendered = await templates.renderByKey(key, values)
if (!rendered) throw new Error(`mailer: no template and no shipped seed for "${key}"`)
if (rendered.missing.length) {
// Not an error: an optional variable a caller chose not to supply renders as
// nothing by design. Logged because the other cause is a renamed variable in
// an operator's edited template, and that one reads as words gone missing.
log.debug('template variables had no value', { key, missing: rendered.missing })
}
return rendered
}
// Ready to send only when enabled, holding a complete credential for a
// registered transport, and knowing which address to send as.
async function isConfigured() {
@@ -100,13 +135,23 @@ async function sendContactMessage({ name, email, message }) {
}
const { transport, config } = built
const to = await contactRecipient(config.senderEmail)
// `fromLabel` and `fromName` are the same missing name with the two different
// fallbacks this message has always used — 'a visitor' in the subject, 'unknown'
// in the body. Kept exactly, and now visible to an operator who wants one word.
const { subject, html, text } = await body('admin.contact-message', {
fromLabel: name || 'a visitor',
fromName: name || 'unknown',
fromEmail: email || 'no email',
message,
})
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}`,
subject,
text,
html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
return { sent: true }
@@ -165,13 +210,18 @@ async function sendTest(to) {
err.code = 'NO_RECIPIENT'
throw err
}
const { subject, html, text } = await body('admin.test', {
transport: config.transport,
sentAt: new Date().toISOString(),
})
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.`,
subject,
text,
html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
return { sent: true, to: recipient }
@@ -196,18 +246,23 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
const built = await buildTransport()
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
// The two conditional fragments stay HERE, where a ternary belongs, and reach
// the template as values. §4.6.2's grammar has no conditional by design.
const roleLabel = role && role !== 'player' ? ` as ${role}` : ''
const by = invitedByName ? ` by ${invitedByName}` : ''
const { subject, html, text } = await body('auth.invite', {
acceptUrl,
roleLabel,
invitedBy: by,
})
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.`,
subject,
text,
html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() })
return { sent: true }
@@ -229,17 +284,15 @@ async function sendPasswordReset({ to, resetUrl, username }) {
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const forWhom = username ? ` for the account “${username}` : ''
const { subject, html, text } = await body('auth.password-reset', { resetUrl, username, forWhom })
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.`,
subject,
text,
html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Password reset send OK', lastVerifiedAt: new Date() })
return { sent: true }
@@ -266,19 +319,15 @@ async function sendEmailVerification({ to, verifyUrl, username }) {
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const forWhom = username ? `${username}` : ''
const { subject, html, text } = await body('auth.email-verify', { verifyUrl, username, forWhom })
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.`,
subject,
text,
html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Verification send OK', lastVerifiedAt: new Date() })
return { sent: true }