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>
97 lines
3.4 KiB
JavaScript
97 lines
3.4 KiB
JavaScript
// ── SMTP — the baseline mail transport ─────────────────────────────────────
|
|
//
|
|
// ENGAGEMENT.md decision 4: Gmail OAuth2 is removed, SMTP is the baseline. This
|
|
// is the only registered transport, and it is deliberately plain SMTP rather than
|
|
// anything provider-shaped — a relay (Mailgun, SES, Postmark), a self-hosted MTA
|
|
// and Gmail-with-an-app-password are all reachable through these five fields, so
|
|
// one transport covers all three postures §7.1 Q5 asks to document.
|
|
//
|
|
// **No defaults for host, port, user or sender.** §3.2 rule 1: a transport with
|
|
// no operator configuration is unconfigured, never pointed at somewhere we chose.
|
|
// `secure` gets a default because it is a protocol choice, not a destination — and
|
|
// even that is only a form default, not a fallback applied to a stored blank.
|
|
//
|
|
// **`secure` is the field operators get wrong**, so its help text says which port
|
|
// each setting means: `secure: true` is implicit TLS on 465, `secure: false` is
|
|
// plaintext-then-STARTTLS on 587 (which nodemailer upgrades automatically). The
|
|
// combination that silently fails is 587 with secure on — the handshake hangs
|
|
// rather than erroring cleanly — which is exactly why "Send test" is the real
|
|
// verification path now (§1.2a consequence 2).
|
|
|
|
const nodemailer = require('nodemailer')
|
|
|
|
const registry = require('./index')
|
|
|
|
const CREDENTIAL_FIELDS = [
|
|
{
|
|
key: 'host',
|
|
label: 'SMTP host',
|
|
kind: 'text',
|
|
required: true,
|
|
placeholder: 'smtp.example.com',
|
|
help: 'Your relay or mail server. No default — nothing is sent until you set this.',
|
|
},
|
|
{
|
|
key: 'port',
|
|
label: 'Port',
|
|
kind: 'number',
|
|
required: true,
|
|
default: 587,
|
|
help: '587 for STARTTLS (most relays), 465 for implicit TLS, 25 for an unauthenticated local MTA.',
|
|
},
|
|
{
|
|
key: 'secure',
|
|
label: 'Implicit TLS',
|
|
kind: 'boolean',
|
|
required: false,
|
|
default: false,
|
|
help: 'On for port 465. Leave off for 587 — the connection still upgrades to TLS via STARTTLS.',
|
|
},
|
|
{
|
|
key: 'user',
|
|
label: 'Username',
|
|
kind: 'text',
|
|
required: false,
|
|
help: 'Leave blank for an unauthenticated local relay.',
|
|
},
|
|
{
|
|
key: 'password',
|
|
label: 'Password / API key',
|
|
kind: 'secret',
|
|
required: false,
|
|
help: 'Stored encrypted and never returned. For Gmail this is an app password, not the account password.',
|
|
},
|
|
]
|
|
|
|
// Authentication is optional (a local MTA on port 25 needs none), so the only
|
|
// hard requirement is a destination. A username without a password is not
|
|
// "complete" — that combination authenticates as nobody and fails at the server.
|
|
function isComplete(credential) {
|
|
const c = credential || {}
|
|
if (!c.host || !Number(c.port)) return false
|
|
if (c.user && !c.password) return false
|
|
return true
|
|
}
|
|
|
|
function build(credential) {
|
|
const c = credential || {}
|
|
const options = {
|
|
host: String(c.host),
|
|
port: Number(c.port),
|
|
secure: Boolean(c.secure),
|
|
}
|
|
if (c.user) options.auth = { user: String(c.user), pass: String(c.password || '') }
|
|
return nodemailer.createTransport(options)
|
|
}
|
|
|
|
registry.registerMailTransport({
|
|
id: 'smtp',
|
|
label: 'SMTP',
|
|
help: 'Any SMTP relay or mail server. See the operator guide for the three supported postures.',
|
|
credentialFields: CREDENTIAL_FIELDS,
|
|
isComplete,
|
|
build,
|
|
})
|
|
|
|
module.exports = { CREDENTIAL_FIELDS, isComplete, build }
|