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,28 @@
const { query } = require('../../utils/db')
const COLS =
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
// Singleton row (id = 1). Returns null until the admin connects Gmail for the first time.
async function get() {
const rows = await query(`SELECT ${COLS} FROM email_config WHERE id = 1 LIMIT 1`)
return rows[0] || null
}
// Upsert the singleton row. `fields` are column values already prepared by the
// model (refresh token pre-encrypted). Only the provided columns are written/updated.
async function upsert(fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['1', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO email_config (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
vals,
)
return get()
}
module.exports = { get, upsert }