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:
2026-07-07 22:29:27 -05:00
parent 17d42cebfe
commit f8652c2399
16 changed files with 966 additions and 56 deletions

View 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)
})