Modernize email: Gmail OAuth2 sending, configured under Settings
Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.
- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
row), contact recipient = contact_email setting, mailto: fallback preserved,
plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
+ PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
This commit is contained in:
70
server/test/emailConfig.model.test.js
Normal file
70
server/test/emailConfig.model.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
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, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const emailConfig = require('../src/model/emailConfig/emailConfig.model')
|
||||
const emailDb = require('../src/model/emailConfig/emailConfig.db')
|
||||
const secretBox = require('../src/utils/secretBox')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// 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 }
|
||||
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 withSecret = await emailConfig.getWithSecret()
|
||||
assert.equal(withSecret.refreshToken, 'refresh-abc')
|
||||
})
|
||||
|
||||
test('getSafe never leaks the refresh token', async () => {
|
||||
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', 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)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
await emailConfig.save({ senderName: 'UOMysticmoon' }) // no refreshToken
|
||||
assert.equal(store.refresh_token_enc, cipherBefore) // untouched
|
||||
assert.equal(store.sender_name, 'UOMysticmoon')
|
||||
assert.equal(secretBox.decrypt(store.refresh_token_enc), 'refresh-abc')
|
||||
})
|
||||
|
||||
test('disconnect clears the credential and disables sending', async () => {
|
||||
await emailConfig.save({ senderEmail: 'me@gmail.com', refreshToken: 'refresh-abc', enabled: true })
|
||||
const safe = await emailConfig.disconnect(7)
|
||||
assert.equal(store.refresh_token_enc, null)
|
||||
assert.equal(store.enabled, 0)
|
||||
assert.equal(safe.hasRefreshToken, false)
|
||||
assert.equal(safe.status, 'unconfigured')
|
||||
})
|
||||
|
||||
test('getSafe returns unconfigured defaults when no row exists', async () => {
|
||||
const safe = await emailConfig.getSafe()
|
||||
assert.equal(safe.enabled, false)
|
||||
assert.equal(safe.hasRefreshToken, false)
|
||||
assert.equal(safe.status, 'unconfigured')
|
||||
assert.equal(safe.senderEmail, null)
|
||||
})
|
||||
72
server/test/mailer.test.js
Normal file
72
server/test/mailer.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
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, beforeEach } = require('node:test')
|
||||
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'
|
||||
})
|
||||
|
||||
test('unconfigured → mailto fallback (never throws)', async () => {
|
||||
emailConfig.getWithSecret = async () => null
|
||||
emailConfig.getSafe = async () => ({ senderEmail: null, hasRefreshToken: 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' }
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
// From uses the display name; To is the contact_email setting; replyTo is the sender.
|
||||
assert.equal(sent.from, '"UOMysticmoon" <shard@gmail.com>')
|
||||
assert.equal(sent.to, 'contact@example.com')
|
||||
assert.equal(sent.replyTo, 'ann@player.com')
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
await assert.rejects(() => mailer.sendContactMessage({ name: 'A', email: 'a@b.com', message: 'x' }), /smtp boom/)
|
||||
assert.equal(recorded.status, 'error')
|
||||
})
|
||||
Reference in New Issue
Block a user