feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user