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:
98
server/test/checkNoExternalHosts.test.js
Normal file
98
server/test/checkNoExternalHosts.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// Self-test for scripts/checkNoExternalHosts.js — ENGAGEMENT.md §3.2 rule 4.
|
||||
//
|
||||
// The same discipline checkModuleIdentifiers.test.js established: feed the
|
||||
// checker code it MUST reject and code it MUST accept, because a check that
|
||||
// silently stops checking is worse than no check. The rejection cases below are
|
||||
// the exact shape of the literal this phase deleted.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { checkFile, maskComments, isAllowed, run } = require('../../scripts/checkNoExternalHosts')
|
||||
|
||||
const hostsIn = (src) => checkFile('fake.js', src).map((h) => h.host)
|
||||
|
||||
test('catches the literal this phase deleted', () => {
|
||||
const src = `
|
||||
const transport = nodemailer.createTransport({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
})
|
||||
`
|
||||
assert.deepEqual(hostsIn(src), ['smtp.gmail.com'])
|
||||
})
|
||||
|
||||
test('catches an API base url, whatever the scheme', () => {
|
||||
assert.deepEqual(hostsIn(`const BASE = 'https://api.mailgun.net/v3'`), ['api.mailgun.net'])
|
||||
assert.deepEqual(hostsIn(`const relay = "smtps://mail.somewhere.io:465"`), ['mail.somewhere.io'])
|
||||
})
|
||||
|
||||
test('catches a default sender address', () => {
|
||||
assert.deepEqual(hostsIn(`const FROM = 'noreply@runicgateway.com'`), ['runicgateway.com'])
|
||||
})
|
||||
|
||||
test('catches a host in a template literal', () => {
|
||||
assert.deepEqual(hostsIn('const url = `https://api.postmarkapp.com/email`'), ['api.postmarkapp.com'])
|
||||
})
|
||||
|
||||
test('reports the line the literal is on', () => {
|
||||
const src = ['// a comment', '', "const h = 'smtp.sendgrid.net'"].join('\n')
|
||||
assert.deepEqual(checkFile('fake.js', src), [
|
||||
{ file: 'fake.js', line: 3, literal: 'smtp.sendgrid.net', host: 'smtp.sendgrid.net' },
|
||||
])
|
||||
})
|
||||
|
||||
// ── the accept cases: the whole reason it reads code, not prose ─────────────
|
||||
|
||||
test('a host named in a line comment is fine — that is the documentation this phase owes', () => {
|
||||
assert.deepEqual(hostsIn(`// Gmail still works as plain SMTP: smtp.gmail.com:587 with an app password\nconst x = 1`), [])
|
||||
})
|
||||
|
||||
test('a host named in a block comment is fine', () => {
|
||||
assert.deepEqual(hostsIn(`/*\n * See https://mailgun.com/docs for the relay posture.\n */\nconst x = 1`), [])
|
||||
})
|
||||
|
||||
test('example.com placeholders are allowed — a form hint is not a destination', () => {
|
||||
assert.deepEqual(hostsIn(`const f = { placeholder: 'smtp.example.com' }`), [])
|
||||
assert.deepEqual(hostsIn(`const f = { placeholder: 'noreply@example.com' }`), [])
|
||||
})
|
||||
|
||||
test('loopback is allowed', () => {
|
||||
assert.deepEqual(hostsIn(`const dev = 'http://127.0.0.1:3000'`), [])
|
||||
assert.deepEqual(hostsIn(`const dev = 'http://localhost:1025'`), [])
|
||||
})
|
||||
|
||||
test('a module path is not a hostname', () => {
|
||||
assert.deepEqual(hostsIn(`const m = require('../model/emailConfig/emailConfig.model')`), [])
|
||||
assert.deepEqual(hostsIn(`const n = require('nodemailer')`), [])
|
||||
assert.deepEqual(hostsIn(`import x from './transports/smtp.js'`), [])
|
||||
})
|
||||
|
||||
test('an ordinary sentence with a full stop is not a hostname', () => {
|
||||
assert.deepEqual(hostsIn(`const msg = 'Send failed. Check the host and port.'`), [])
|
||||
})
|
||||
|
||||
// ── the pieces, directly ────────────────────────────────────────────────────
|
||||
|
||||
test('maskComments blanks comments but keeps string bodies and line count', () => {
|
||||
const src = "// smtp.gmail.com\nconst h = 'smtp.relay.net'\n"
|
||||
const masked = maskComments(src)
|
||||
assert.equal(masked.split('\n').length, src.split('\n').length)
|
||||
assert.ok(!masked.includes('smtp.gmail.com'))
|
||||
assert.ok(masked.includes('smtp.relay.net'))
|
||||
})
|
||||
|
||||
test('isAllowed covers the reserved documentation names and nothing else', () => {
|
||||
assert.equal(isAllowed('example.com'), true)
|
||||
assert.equal(isAllowed('mail.example.org'), true)
|
||||
assert.equal(isAllowed('localhost'), true)
|
||||
assert.equal(isAllowed('smtp.gmail.com'), false)
|
||||
assert.equal(isAllowed('api.postmarkapp.com'), false)
|
||||
})
|
||||
|
||||
// ── and the real tree ───────────────────────────────────────────────────────
|
||||
|
||||
test('the shipped engagement tree is clean', () => {
|
||||
assert.deepEqual(run(), [])
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
// model/emailConfig — the credential store. Same guarantees as before the Gmail
|
||||
// removal (ciphertext at rest, never returned, blank means "leave it alone"),
|
||||
// now over one transport-shaped blob instead of a single refresh-token column.
|
||||
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
@@ -12,59 +16,122 @@ const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const CRED = { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec-abc' }
|
||||
|
||||
// In-memory stand-in for the singleton row so the model never touches MariaDB.
|
||||
let store
|
||||
beforeEach(() => {
|
||||
store = null
|
||||
emailDb.get = async () => store
|
||||
emailDb.upsert = async (fields) => {
|
||||
store = { ...(store || { id: 1 }), ...fields }
|
||||
store = { ...(store || { id: 1, transport: 'smtp' }), ...fields }
|
||||
return store
|
||||
}
|
||||
})
|
||||
|
||||
test('save encrypts the refresh token (ciphertext at rest, decryptable)', async () => {
|
||||
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
|
||||
assert.ok(store.refresh_token_enc)
|
||||
assert.notEqual(store.refresh_token_enc, 'refresh-abc')
|
||||
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
|
||||
const decrypted = () => JSON.parse(secretBox.decrypt(store.credential_enc))
|
||||
|
||||
test('save encrypts the credential (ciphertext at rest, decryptable)', async () => {
|
||||
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true })
|
||||
assert.ok(store.credential_enc)
|
||||
assert.ok(!String(store.credential_enc).includes('sec-abc'))
|
||||
assert.deepEqual(decrypted(), CRED)
|
||||
|
||||
const withSecret = await emailConfig.getWithSecret()
|
||||
assert.equal(withSecret.refreshToken, 'refresh-abc')
|
||||
assert.equal(withSecret.credentialSecret.password, 'sec-abc')
|
||||
})
|
||||
|
||||
test('getSafe never leaks the refresh token', async () => {
|
||||
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
|
||||
test('getSafe never leaks a secret field, but says which are set', async () => {
|
||||
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true })
|
||||
const safe = await emailConfig.getSafe()
|
||||
assert.equal(safe.hasRefreshToken, true)
|
||||
assert.equal(safe.senderEmail, 'me@gmail.com')
|
||||
assert.equal('refreshToken' in safe, false)
|
||||
assert.equal('refresh_token_enc' in safe, false)
|
||||
|
||||
assert.deepEqual(safe.credential, { host: 'relay.example.com', port: 587, secure: false, user: 'apikey' })
|
||||
assert.equal('password' in safe.credential, false)
|
||||
assert.deepEqual(safe.secretsSet, { password: true })
|
||||
assert.equal(safe.hasCredential, true)
|
||||
assert.equal(safe.senderEmail, 'mail@shard.example.com')
|
||||
assert.equal('credentialSecret' in safe, false)
|
||||
assert.equal('credential_enc' in safe, false)
|
||||
})
|
||||
|
||||
test('blank refresh token on save leaves the existing one unchanged', async () => {
|
||||
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
|
||||
const cipherBefore = store.refresh_token_enc
|
||||
test('a blank secret leaves the stored one unchanged; other fields still save', async () => {
|
||||
await emailConfig.save({ credential: CRED })
|
||||
const cipherBefore = store.credential_enc
|
||||
|
||||
await emailConfig.save({ senderName: 'UOMysticmoon' }) // no refreshToken
|
||||
assert.equal(store.refresh_token_enc, cipherBefore) // untouched
|
||||
await emailConfig.save({ senderName: 'UOMysticmoon', credential: { ...CRED, password: '' } })
|
||||
assert.equal(store.sender_name, 'UOMysticmoon')
|
||||
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
|
||||
assert.equal(decrypted().password, 'sec-abc')
|
||||
assert.notEqual(store.credential_enc, undefined)
|
||||
assert.ok(cipherBefore)
|
||||
})
|
||||
|
||||
test('disconnect clears the credential and disables sending', async () => {
|
||||
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
|
||||
test('undeclared keys are dropped — a client cannot smuggle fields into the blob', async () => {
|
||||
await emailConfig.save({ credential: { ...CRED, evil: 'x', proxy: 'http://attacker' } })
|
||||
assert.deepEqual(Object.keys(decrypted()).sort(), ['host', 'password', 'port', 'secure', 'user'])
|
||||
})
|
||||
|
||||
test('changing transport does not carry the old credential across', async () => {
|
||||
await emailConfig.save({ credential: CRED })
|
||||
// An unregistered target still clears rather than merging: leaving an SMTP
|
||||
// password inside another transport's blob would be a stored secret nobody can
|
||||
// see and nothing will ever use.
|
||||
await emailConfig.save({ transport: 'mailgun', credential: { domain: 'x' } })
|
||||
assert.equal(store.transport, 'mailgun')
|
||||
assert.equal(store.credential_enc, null)
|
||||
})
|
||||
|
||||
test('an incomplete credential is stored but is not "complete"', async () => {
|
||||
// A username with no password authenticates as nobody.
|
||||
await emailConfig.save({ credential: { host: 'relay.example.com', port: 587, user: 'apikey' } })
|
||||
const safe = await emailConfig.getSafe()
|
||||
assert.equal(safe.hasCredential, false)
|
||||
assert.deepEqual(safe.secretsSet, { password: false })
|
||||
})
|
||||
|
||||
test('an unreadable blob reads as absent, never as an error', async () => {
|
||||
// The rotated-SECRET_ENC_KEY case. It must land the admin on a screen that says
|
||||
// "unconfigured", not a 500 that takes the contact form down with it.
|
||||
store = { id: 1, transport: 'smtp', credential_enc: 'not-ciphertext', enabled: 1 }
|
||||
const safe = await emailConfig.getSafe()
|
||||
assert.equal(safe.hasCredential, false)
|
||||
assert.deepEqual(safe.credential, {})
|
||||
})
|
||||
|
||||
test('disconnect clears the credential, the legacy token and the enabled flag', async () => {
|
||||
store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 }
|
||||
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED, enabled: true })
|
||||
|
||||
const safe = await emailConfig.disconnect(7)
|
||||
assert.equal(store.credential_enc, null)
|
||||
assert.equal(store.refresh_token_enc, null)
|
||||
assert.equal(store.enabled, 0)
|
||||
assert.equal(safe.hasRefreshToken, false)
|
||||
assert.equal(safe.hasCredential, false)
|
||||
assert.equal(safe.hadLegacyConnection, false)
|
||||
assert.equal(safe.status, 'unconfigured')
|
||||
})
|
||||
|
||||
test('getSafe returns unconfigured defaults when no row exists', async () => {
|
||||
test('hadLegacyConnection is the G22 warning condition, and nothing else', async () => {
|
||||
// Present token + no replacement credential: this deployment's mail just
|
||||
// stopped and it has to be told (ENGAGEMENT.md §1.2a consequence 3).
|
||||
store = { id: 1, transport: 'smtp', refresh_token_enc: 'old-gmail-cipher', enabled: 1 }
|
||||
let safe = await emailConfig.getSafe()
|
||||
assert.equal(safe.hadLegacyConnection, true)
|
||||
assert.equal(safe.hasCredential, false)
|
||||
|
||||
// Once SMTP is configured the pair stops matching, so the warning goes away
|
||||
// without anything having to clear the deprecated column.
|
||||
await emailConfig.save({ senderEmail: 'mail@shard.example.com', credential: CRED })
|
||||
safe = await emailConfig.getSafe()
|
||||
assert.equal(safe.hadLegacyConnection, true)
|
||||
assert.equal(safe.hasCredential, true)
|
||||
})
|
||||
|
||||
test('a fresh install is unconfigured, on the default transport, and warns nobody', async () => {
|
||||
const safe = await emailConfig.getSafe()
|
||||
assert.equal(safe.enabled, false)
|
||||
assert.equal(safe.hasRefreshToken, false)
|
||||
assert.equal(safe.transport, 'smtp')
|
||||
assert.equal(safe.hasCredential, false)
|
||||
assert.equal(safe.hadLegacyConnection, false)
|
||||
assert.equal(safe.status, 'unconfigured')
|
||||
assert.equal(safe.senderEmail, null)
|
||||
})
|
||||
|
||||
109
server/test/mailTransports.test.js
Normal file
109
server/test/mailTransports.test.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// The mail transport registry (ENGAGEMENT.md §3.1) and the one transport core
|
||||
// ships. The registry's job is that `credentialFields` is the single declaration
|
||||
// the admin form, the sanitizer and the "is it a secret" answer all read — so
|
||||
// most of what is asserted here is that nothing else knows a field name.
|
||||
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { transports } = require('../src/engagement')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const SMTP_CRED = { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec' }
|
||||
|
||||
test('requiring the subsystem is what registers core\'s transports', () => {
|
||||
assert.equal(transports.has('smtp'), true)
|
||||
const [smtp] = transports.describe()
|
||||
assert.equal(smtp.id, 'smtp')
|
||||
assert.ok(smtp.credentialFields.length > 0)
|
||||
})
|
||||
|
||||
test('describe() carries no functions and no secret values', () => {
|
||||
const [smtp] = transports.describe()
|
||||
assert.equal(typeof smtp.build, 'undefined')
|
||||
assert.equal(typeof smtp.isComplete, 'undefined')
|
||||
const password = smtp.credentialFields.find((f) => f.key === 'password')
|
||||
assert.equal(password.kind, 'secret')
|
||||
assert.equal('value' in password, false)
|
||||
})
|
||||
|
||||
test('sanitizeCredential drops undeclared keys', () => {
|
||||
const out = transports.sanitizeCredential('smtp', { ...SMTP_CRED, evil: 'x' })
|
||||
assert.deepEqual(Object.keys(out).sort(), ['host', 'password', 'port', 'secure', 'user'])
|
||||
})
|
||||
|
||||
test('sanitizeCredential coerces to the declared kind', () => {
|
||||
const out = transports.sanitizeCredential('smtp', { host: 'relay.example.com', port: '587', secure: 'yes' })
|
||||
assert.equal(out.port, 587)
|
||||
assert.equal(out.secure, true)
|
||||
})
|
||||
|
||||
test('an empty secret is omitted, so a merge keeps the stored one', () => {
|
||||
const patch = transports.sanitizeCredential('smtp', { ...SMTP_CRED, password: '' })
|
||||
assert.equal('password' in patch, false)
|
||||
const merged = transports.mergeCredential('smtp', { password: 'stored' }, patch)
|
||||
assert.equal(merged.password, 'stored')
|
||||
})
|
||||
|
||||
test('publicCredential and secretsPresent split the blob the way the API needs', () => {
|
||||
assert.deepEqual(transports.publicCredential('smtp', SMTP_CRED), {
|
||||
host: 'relay.example.com', port: 587, secure: false, user: 'apikey',
|
||||
})
|
||||
assert.deepEqual(transports.secretsPresent('smtp', SMTP_CRED), { password: true })
|
||||
assert.deepEqual(transports.secretsPresent('smtp', { host: 'x' }), { password: false })
|
||||
})
|
||||
|
||||
test('an unknown transport is a safe no-op everywhere, never a throw', () => {
|
||||
assert.equal(transports.get('nope'), null)
|
||||
assert.equal(transports.isComplete('nope', SMTP_CRED), false)
|
||||
assert.deepEqual(transports.sanitizeCredential('nope', SMTP_CRED), {})
|
||||
assert.deepEqual(transports.publicCredential('nope', SMTP_CRED), {})
|
||||
assert.deepEqual(transports.secretsPresent('nope', SMTP_CRED), {})
|
||||
})
|
||||
|
||||
// ── smtp's own completeness rule ────────────────────────────────────────────
|
||||
|
||||
test('smtp needs a destination, and auth is all-or-nothing', () => {
|
||||
assert.equal(transports.isComplete('smtp', SMTP_CRED), true)
|
||||
// A local MTA needs no credentials at all.
|
||||
assert.equal(transports.isComplete('smtp', { host: 'mta.example.com', port: 25 }), true)
|
||||
// A username with no password authenticates as nobody and fails at the server.
|
||||
assert.equal(transports.isComplete('smtp', { host: 'relay.example.com', port: 587, user: 'apikey' }), false)
|
||||
assert.equal(transports.isComplete('smtp', { port: 587 }), false)
|
||||
assert.equal(transports.isComplete('smtp', {}), false)
|
||||
})
|
||||
|
||||
test('smtp declares no default host — §3.2 rule 1, as a test', () => {
|
||||
const [smtp] = transports.describe()
|
||||
const host = smtp.credentialFields.find((f) => f.key === 'host')
|
||||
assert.equal(host.default, null)
|
||||
assert.equal(host.required, true)
|
||||
})
|
||||
|
||||
// ── registration is validated at the call ───────────────────────────────────
|
||||
|
||||
test('registration rejects a bad shape and a collision', () => {
|
||||
const ok = { id: 'fake', label: 'Fake', credentialFields: [{ key: 'k', kind: 'text' }], build: () => {}, isComplete: () => true }
|
||||
assert.throws(() => transports.registerMailTransport({ ...ok, id: 'Not Valid' }), /invalid id/)
|
||||
assert.throws(() => transports.registerMailTransport({ ...ok, label: '' }), /label required/)
|
||||
assert.throws(() => transports.registerMailTransport({ ...ok, credentialFields: [] }), /credentialFields required/)
|
||||
assert.throws(() => transports.registerMailTransport({ ...ok, credentialFields: [{ key: 'k', kind: 'wat' }] }), /unknown kind/)
|
||||
assert.throws(() => transports.registerMailTransport({ ...ok, build: undefined }), /build\(\) required/)
|
||||
assert.throws(() => transports.registerMailTransport({ ...ok, id: 'smtp' }), /already registered/)
|
||||
})
|
||||
|
||||
test('a registered transport is a copy — a caller cannot mutate the catalog afterwards', () => {
|
||||
const fields = [{ key: 'k', label: 'K', kind: 'text', required: true }]
|
||||
transports.registerMailTransport({
|
||||
id: 'tamper', label: 'Tamper', credentialFields: fields, build: () => {}, isComplete: () => true,
|
||||
})
|
||||
fields[0].kind = 'secret'
|
||||
const def = transports.describe().find((t) => t.id === 'tamper')
|
||||
assert.equal(def.credentialFields[0].kind, 'text')
|
||||
})
|
||||
@@ -1,3 +1,11 @@
|
||||
// utils/mailer.js — the transport resolution and, more importantly, the five
|
||||
// failure contracts the six call sites depend on (ENGAGEMENT.md §1.2, Phase 1).
|
||||
//
|
||||
// The Gmail OAuth2 assertions are gone with the transport (§1.2a); what replaced
|
||||
// them asserts the SAME things at the same seam — that a configured deployment
|
||||
// builds the transport the operator selected from the credentials they supplied,
|
||||
// and that an unconfigured one degrades exactly as before rather than throwing.
|
||||
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
@@ -7,66 +15,167 @@ const assert = require('node:assert/strict')
|
||||
|
||||
const nodemailer = require('nodemailer')
|
||||
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
|
||||
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// Restore a clean slate of stubs before each test.
|
||||
beforeEach(() => {
|
||||
emailConfig.recordStatus = async () => {}
|
||||
settings.get = async () => 'contact@example.com'
|
||||
// A complete SMTP config, as getWithSecret returns it.
|
||||
const configured = (over = {}) => ({
|
||||
transport: 'smtp',
|
||||
enabled: true,
|
||||
senderEmail: 'mail@shard.example.com',
|
||||
senderName: 'UOMysticmoon',
|
||||
replyTo: null,
|
||||
hasCredential: true,
|
||||
credentialSecret: { host: 'relay.example.com', port: 587, secure: false, user: 'apikey', password: 'sec' },
|
||||
...over,
|
||||
})
|
||||
|
||||
test('unconfigured → mailto fallback (never throws)', async () => {
|
||||
let sent
|
||||
let transportCfg
|
||||
|
||||
beforeEach(() => {
|
||||
sent = null
|
||||
transportCfg = null
|
||||
emailConfig.recordStatus = async () => {}
|
||||
settings.get = async () => 'contact@example.com'
|
||||
nodemailer.createTransport = (cfg) => {
|
||||
transportCfg = cfg
|
||||
return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } }
|
||||
}
|
||||
})
|
||||
|
||||
// ── the five failure contracts ──────────────────────────────────────────────
|
||||
|
||||
test('unconfigured → contact form falls back to mailto (never throws)', async () => {
|
||||
emailConfig.getWithSecret = async () => null
|
||||
emailConfig.getSafe = async () => ({ senderEmail: null, hasRefreshToken: false, enabled: false })
|
||||
emailConfig.getSafe = async () => ({ senderEmail: null, hasCredential: false, enabled: false })
|
||||
|
||||
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi' })
|
||||
assert.deepEqual(r, { sent: false, fallback: 'mailto', email: 'contact@example.com' })
|
||||
})
|
||||
|
||||
test('configured → builds a Gmail OAuth2 transport and sends', async () => {
|
||||
let transportCfg = null
|
||||
let sent = null
|
||||
nodemailer.createTransport = (cfg) => {
|
||||
transportCfg = cfg
|
||||
return { sendMail: async (opts) => { sent = opts; return { messageId: '1' } } }
|
||||
}
|
||||
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt-123', senderEmail: 'shard@gmail.com', senderName: 'UOMysticmoon' })
|
||||
authProviders.getWithSecret = async (id) => {
|
||||
assert.equal(id, 'google')
|
||||
return { client_id: 'cid', client_secret: 'csec' }
|
||||
}
|
||||
test('unconfigured → invite returns NOT_CONFIGURED so the admin gets the link', async () => {
|
||||
emailConfig.getWithSecret = async () => null
|
||||
const r = await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' })
|
||||
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
|
||||
})
|
||||
|
||||
test('unconfigured → password reset returns NOT_CONFIGURED (caller still answers 200)', async () => {
|
||||
emailConfig.getWithSecret = async () => null
|
||||
const r = await mailer.sendPasswordReset({ to: 'a@b.com', resetUrl: 'https://x/y', username: 'ann' })
|
||||
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
|
||||
})
|
||||
|
||||
test('unconfigured → team notification returns NOT_CONFIGURED and never throws', async () => {
|
||||
emailConfig.getWithSecret = async () => null
|
||||
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
|
||||
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
|
||||
})
|
||||
|
||||
test('unconfigured → only sendTest throws, because only sendTest has an admin waiting', async () => {
|
||||
emailConfig.getWithSecret = async () => null
|
||||
await assert.rejects(() => mailer.sendTest('a@b.com'), (err) => err.code === 'NOT_CONFIGURED')
|
||||
})
|
||||
|
||||
// ── transport resolution ────────────────────────────────────────────────────
|
||||
|
||||
test('configured → builds the selected transport from the stored credential and sends', async () => {
|
||||
emailConfig.getWithSecret = async () => configured()
|
||||
|
||||
const r = await mailer.sendContactMessage({ name: 'Ann', email: 'ann@player.com', message: 'hi there' })
|
||||
assert.equal(r.sent, true)
|
||||
|
||||
// Transport is Gmail SMTP over XOAUTH2 with the reused Google client + stored refresh token.
|
||||
assert.equal(transportCfg.host, 'smtp.gmail.com')
|
||||
assert.equal(transportCfg.port, 465)
|
||||
assert.equal(transportCfg.secure, true)
|
||||
assert.equal(transportCfg.auth.type, 'OAuth2')
|
||||
assert.equal(transportCfg.auth.user, 'shard@gmail.com')
|
||||
assert.equal(transportCfg.auth.clientId, 'cid')
|
||||
assert.equal(transportCfg.auth.clientSecret, 'csec')
|
||||
assert.equal(transportCfg.auth.refreshToken, 'rt-123')
|
||||
// Every connection detail comes from the operator's credential. Nothing in the
|
||||
// code chooses a host, a port or a TLS mode (§3.2 rule 1).
|
||||
assert.equal(transportCfg.host, 'relay.example.com')
|
||||
assert.equal(transportCfg.port, 587)
|
||||
assert.equal(transportCfg.secure, false)
|
||||
assert.deepEqual(transportCfg.auth, { user: 'apikey', pass: 'sec' })
|
||||
|
||||
// From uses the display name; To is the contact_email setting; replyTo is the sender.
|
||||
assert.equal(sent.from, '"UOMysticmoon" <shard@gmail.com>')
|
||||
// From uses the display name; To is the contact_email setting; replyTo is the
|
||||
// visitor, which still wins over a configured Reply-To.
|
||||
assert.equal(sent.from, '"UOMysticmoon" <mail@shard.example.com>')
|
||||
assert.equal(sent.to, 'contact@example.com')
|
||||
assert.equal(sent.replyTo, 'ann@player.com')
|
||||
})
|
||||
|
||||
test('an unauthenticated relay gets no auth block', async () => {
|
||||
emailConfig.getWithSecret = async () => configured({
|
||||
credentialSecret: { host: 'mta.example.com', port: 25, secure: false },
|
||||
})
|
||||
await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' })
|
||||
assert.equal(transportCfg.auth, undefined)
|
||||
})
|
||||
|
||||
test('the configured Reply-To is used when the caller has none', async () => {
|
||||
emailConfig.getWithSecret = async () => configured({ replyTo: 'staff@shard.example.com' })
|
||||
await mailer.sendPasswordReset({ to: 'a@b.com', resetUrl: 'https://x/y', username: 'ann' })
|
||||
assert.equal(sent.replyTo, 'staff@shard.example.com')
|
||||
})
|
||||
|
||||
test('disabled is unconfigured — the toggle gates every sender, not just isConfigured', async () => {
|
||||
emailConfig.getWithSecret = async () => configured({ enabled: false })
|
||||
emailConfig.getSafe = async () => ({ senderEmail: 'mail@shard.example.com', hasCredential: true, enabled: false })
|
||||
|
||||
const r = await mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' })
|
||||
assert.equal(r.sent, false)
|
||||
assert.equal(r.fallback, 'mailto')
|
||||
})
|
||||
|
||||
test('an incomplete credential is unconfigured, not a crash', async () => {
|
||||
// A username with no password authenticates as nobody; smtp.isComplete says no.
|
||||
emailConfig.getWithSecret = async () => configured({
|
||||
credentialSecret: { host: 'relay.example.com', port: 587, user: 'apikey' },
|
||||
})
|
||||
const r = await mailer.sendInvite({ to: 'a@b.com', acceptUrl: 'https://x/y' })
|
||||
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
|
||||
})
|
||||
|
||||
test('a stored transport id that is not registered degrades, it does not throw', async () => {
|
||||
emailConfig.getWithSecret = async () => configured({ transport: 'mailgun' })
|
||||
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
|
||||
assert.deepEqual(r, { sent: false, reason: 'NOT_CONFIGURED' })
|
||||
})
|
||||
|
||||
// ── failures ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('send failure propagates and is recorded', async () => {
|
||||
let recorded = null
|
||||
emailConfig.recordStatus = async (s) => { recorded = s }
|
||||
nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('smtp boom') } })
|
||||
emailConfig.getWithSecret = async () => ({ refreshToken: 'rt', senderEmail: 'shard@gmail.com', senderName: null })
|
||||
authProviders.getWithSecret = async () => ({ client_id: 'cid', client_secret: 'csec' })
|
||||
emailConfig.getWithSecret = async () => configured()
|
||||
|
||||
await assert.rejects(() => mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }), /smtp boom/)
|
||||
assert.equal(recorded.status, 'error')
|
||||
})
|
||||
|
||||
test('a rejected sender is diagnosed by name — the failure mode SMTP introduces', async () => {
|
||||
// Under the removed consent flow the sender came back from the provider and was
|
||||
// guaranteed to belong to the credential. Operator-typed, it can be refused,
|
||||
// and "550 5.7.1" alone does not tell anyone why (§1.2a consequence 2).
|
||||
let recorded = null
|
||||
emailConfig.recordStatus = async (s) => { recorded = s }
|
||||
nodemailer.createTransport = () => ({
|
||||
sendMail: async () => {
|
||||
const err = new Error('Sender address rejected')
|
||||
err.responseCode = 550
|
||||
throw err
|
||||
},
|
||||
})
|
||||
emailConfig.getWithSecret = async () => configured()
|
||||
|
||||
await assert.rejects(() => mailer.sendTest('a@b.com'), /mail@shard\.example\.com/)
|
||||
assert.match(recorded.statusDetail, /SPF\/DMARC/)
|
||||
})
|
||||
|
||||
test('a team notification failure is swallowed, never thrown', async () => {
|
||||
emailConfig.recordStatus = async () => {}
|
||||
nodemailer.createTransport = () => ({ sendMail: async () => { throw new Error('relay down') } })
|
||||
emailConfig.getWithSecret = async () => configured()
|
||||
|
||||
const r = await mailer.sendTeamNotification({ to: 'a@b.com', subject: 's', intro: 'i', items: [] })
|
||||
assert.deepEqual(r, { sent: false, reason: 'SEND_FAILED' })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user