feat(email): remove Gmail OAuth2, put SMTP behind a transport registry
Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A subtraction and a replacement in one commit, because leaving the OAuth2 flow half-wired across a release is worse than either end state. Deleted, per the §1.2a inventory: GET /admin/email/connect/start and /connect/callback, the connectStart/connectCallback controllers with the email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing, the https://mail.google.com/ scope, the borrowed `google` auth-providers client, the OAuth2 nodemailer transport with its smtp.gmail.com:465 literals, the refresh-token decrypt in the model, and the client's Connect Gmail button, redirect banner and six Gmail error strings. `provider` and `refresh_token_enc` stay as columns under the additive-only discipline, unread. Added: a mail transport registry (server/src/engagement/transports) with `smtp` as the sole registration. `credentialFields` is the single declaration the admin form renders, the sanitizer filters against, and the "is it secret" answer comes from, so adding a transport is a registration rather than four edits. email_config gains transport / credential_enc (one encrypted JSON blob, since the field list is the transport's to declare) / reply_to. All six call sites keep their exact failure contracts: the contact form's mailto fallback, the invite's copyable link, the reset's generic 200, and sendTeamNotification's never-throws. One deliberate behaviour change: `enabled` now gates every sender rather than only isConfigured() — the connect flow used to set it as a side effect, and with a credential form the toggle has to mean what it says. Send-test becomes the real verification. Under OAuth2 the sender came back from Google and was guaranteed to belong to the credential; operator-typed, it can be refused, so failures name the sender and the SPF/DMARC reason (§1.2a consequence 2). G22, the silent degradation: an upgraded deployment backfills to smtp with no credentials and every sink politely does nothing. The admin dashboard now warns when the deprecated Gmail token is present and no replacement credential is, so the one deployment this happens to is told. A fresh install has never had mail and is not nagged. Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own self-test, wired into pr-checks before the install; routes.manifest and routes.guards regenerated (-2 routes). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,65 +1,93 @@
|
||||
// ── Outbound mail (Gmail over OAuth2 / SMTP XOAUTH2) ───────────────────────
|
||||
// ── Outbound mail ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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.
|
||||
// `email_config` singleton holds the enabled flag, the sender identity and the
|
||||
// AES-GCM-encrypted credential for whichever transport is selected; the transport
|
||||
// itself is a registration in `src/engagement/transports` (ENGAGEMENT.md §3.1),
|
||||
// so which provider is used is DATA, not a code path in this file.
|
||||
//
|
||||
// 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')
|
||||
// Gmail OAuth2 was removed in engagement Phase 1 (§1.2a). Gmail is still reachable
|
||||
// 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.
|
||||
//
|
||||
// **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
|
||||
// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite
|
||||
// and sendPasswordReset return { sent: false, reason: 'NOT_CONFIGURED' } so their
|
||||
// callers can surface a link / answer a generic 200, sendTeamNotification never
|
||||
// throws at all, and only sendTest throws — because only sendTest has an admin
|
||||
// waiting to be told why.
|
||||
|
||||
const emailConfig = require('../model/emailConfig/emailConfig.model')
|
||||
const authProviders = require('../model/authProviders/authProviders.model')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const { transports } = require('../engagement')
|
||||
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.
|
||||
// Ready to send only when enabled, holding a complete credential for a
|
||||
// registered transport, and knowing which address to send as.
|
||||
async function isConfigured() {
|
||||
const c = await emailConfig.getSafe()
|
||||
return Boolean(c.enabled && c.hasRefreshToken && c.senderEmail)
|
||||
return Boolean(c.enabled && c.hasCredential && c.senderEmail)
|
||||
}
|
||||
|
||||
// Recipient for the contact form: the admin-editable contact_email setting, or
|
||||
// the connected sending address as a last resort.
|
||||
// the configured 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.
|
||||
/**
|
||||
* Build a transport from the stored config. Returns { transport, config } or
|
||||
* null when unconfigured — every sender handles null itself.
|
||||
*
|
||||
* Null, never a throw, for all five ways this can fail: no row, disabled, no
|
||||
* sender address, an incomplete credential, or a stored transport id that is not
|
||||
* registered (a downgrade, or a provider removed from the build). The last one is
|
||||
* the reason `transports.get()` is checked rather than assumed: a send-time
|
||||
* exception from an unknown id would break the contact form for a reason the
|
||||
* admin screen already shows.
|
||||
*
|
||||
* **`enabled` is checked here now, and it was not before.** Under the connect
|
||||
* flow this function tested only "is there a refresh token and a sender", so the
|
||||
* contact form kept sending after an admin unticked "Enable email sending" —
|
||||
* `isConfigured()` honoured the toggle but the direct senders bypassed it. With
|
||||
* the toggle no longer set as a side effect of a consent redirect it has to mean
|
||||
* what it says, so the gate lives on the one path every sender shares.
|
||||
*/
|
||||
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')
|
||||
if (!config || !config.enabled || !config.senderEmail) return null
|
||||
const def = transports.get(config.transport)
|
||||
if (!def) {
|
||||
log.warn('email send skipped: no such transport', { transport: config.transport })
|
||||
return null
|
||||
}
|
||||
if (!transports.isComplete(config.transport, config.credentialSecret)) {
|
||||
log.warn('email send skipped: transport credentials are incomplete', { transport: config.transport })
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return { transport: def.build(config.credentialSecret, config), config }
|
||||
} catch (err) {
|
||||
log.error('could not build the mail transport', err)
|
||||
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
|
||||
}
|
||||
|
||||
// Reply-To is the operator's optional override; the contact form's per-message
|
||||
// replyTo (the visitor's address) wins over it, which is the whole reason the
|
||||
// contact form has one.
|
||||
function replyToFor(config, override) {
|
||||
return override || config.replyTo || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a contact message. If email is not configured/enabled, signals the caller
|
||||
* to fall back to a mailto: link instead of throwing.
|
||||
@@ -76,7 +104,7 @@ async function sendContactMessage({ name, email, message }) {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: email,
|
||||
replyTo: replyToFor(config, email),
|
||||
subject: `${brand.name} contact from ${name || 'a visitor'}`,
|
||||
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
|
||||
})
|
||||
@@ -84,19 +112,49 @@ async function sendContactMessage({ name, email, message }) {
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('contact send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err) })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a transport error into something an operator can act on.
|
||||
*
|
||||
* This matters more than it used to. Under Gmail OAuth2 the sending address came
|
||||
* back from Google's userinfo and was guaranteed to be a mailbox the credential
|
||||
* owned. Under SMTP `sender_email` is operator-typed, so a relay rejecting the
|
||||
* envelope From is now a live failure mode (§1.2a consequence 2) — and it arrives
|
||||
* as a bare "550 5.7.1" that means nothing without the sender in front of it.
|
||||
*/
|
||||
function describeSendError(err, config) {
|
||||
const code = err && (err.responseCode || err.code)
|
||||
const base = (err && (err.response || err.message)) || 'Send failed'
|
||||
const sender = config && config.senderEmail
|
||||
if (sender && (code === 550 || code === 553 || code === 554 || code === 'EENVELOPE')) {
|
||||
return `${base} — the server refused "${sender}" as the sender. It must be an address this account is allowed to send as (SPF/DMARC).`
|
||||
}
|
||||
if (code === 'EAUTH') return `${base} — the username or password was rejected.`
|
||||
if (code === 'ESOCKET' || code === 'ECONNECTION') {
|
||||
return `${base} — could not connect. Check the host, the port, and whether "Implicit TLS" matches it (on for 465, off for 587).`
|
||||
}
|
||||
if (code === 'ETIMEDOUT') {
|
||||
return `${base} — the connection timed out. A common cause is "Implicit TLS" left on for port 587.`
|
||||
}
|
||||
return String(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* **This is now the real verification of the whole configuration** — host, port,
|
||||
* TLS mode, credentials AND whether the relay will accept the operator-typed
|
||||
* sender. There is no consent flow left to prove any of it beforehand.
|
||||
*/
|
||||
async function sendTest(to) {
|
||||
const built = await buildTransport()
|
||||
if (!built) {
|
||||
const err = new Error('Email is not configured. Connect Gmail first.')
|
||||
const err = new Error('Email is not configured. Set a transport, its credentials and a sender address first.')
|
||||
err.code = 'NOT_CONFIGURED'
|
||||
throw err
|
||||
}
|
||||
@@ -111,15 +169,19 @@ async function sendTest(to) {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to: recipient,
|
||||
replyTo: replyToFor(config),
|
||||
subject: `${brand.name} email test`,
|
||||
text: 'This is a test message confirming Gmail OAuth2 email delivery is working.',
|
||||
text: `This is a test message confirming ${config.transport} email delivery is working.`,
|
||||
})
|
||||
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
|
||||
return { sent: true, to: recipient }
|
||||
} catch (err) {
|
||||
const detail = describeSendError(err, config)
|
||||
log.error('test send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
throw err
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: detail })
|
||||
const wrapped = new Error(detail)
|
||||
wrapped.code = err.code || 'SEND_FAILED'
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +202,7 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
|
||||
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` +
|
||||
@@ -150,7 +213,7 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('invite send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -171,6 +234,7 @@ async function sendPasswordReset({ to, resetUrl, username }) {
|
||||
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` +
|
||||
@@ -182,7 +246,7 @@ async function sendPasswordReset({ to, resetUrl, username }) {
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
log.error('password reset send failed', err)
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message })
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -234,6 +298,7 @@ async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubs
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
replyTo: replyToFor(config),
|
||||
subject,
|
||||
text: lines.join('\n'),
|
||||
// The header carries the API url, not the one in the body: a one-click
|
||||
@@ -252,7 +317,7 @@ async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubs
|
||||
// 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(() => {})
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) }).catch(() => {})
|
||||
return { sent: false, reason: 'SEND_FAILED' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user