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:
2026-08-28 20:41:43 -05:00
parent e25e7ade80
commit 47c8b37d45
26 changed files with 1535 additions and 461 deletions

View File

@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const registries = require('../../../modules/registries')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml')
@@ -55,6 +56,39 @@ async function announceIfNewlyPublished(post, transition) {
}
// ── Dashboard & site mode ─────────────────────────────────────────────
// G22 — the Gmail removal degrades SILENTLY (ENGAGEMENT.md §1.2a consequence 3).
// On upgrade, `transport` backfills to `smtp` with no credentials, so every sink
// politely does nothing: the contact form falls back to `mailto`, invites surface
// a copyable link, password resets still answer a generic 200. Nothing breaks
// loudly, which is exactly the risk — email stops and nobody is told.
//
// So something has to say it. The condition is deliberately narrow: a deployment
// that still holds the deprecated Gmail refresh token (it had working mail) and
// has no replacement credential (it does not any more). A fresh install has never
// had mail and is not warned — a nag about a capability nobody asked for is a
// banner people learn to ignore, and this one has to be believed exactly once.
//
// Never fails the dashboard. A warning that can 500 the admin landing page is a
// worse bug than the one it reports.
async function emailWarning() {
try {
const c = await emailConfig.getSafe()
if (!c.hadLegacyConnection || c.hasCredential) return null
return {
code: 'EMAIL_TRANSPORT_MIGRATION',
message:
'Outbound email is not configured. This deployment used the Gmail connect flow, which has been ' +
'removed — mail is no longer being sent. Add SMTP credentials under Settings → Email. Gmail still ' +
'works as an ordinary SMTP relay with an app password.',
href: '/admin/settings',
}
} catch (err) {
log.warn('dashboard email warning check failed', { message: err.message })
return null
}
}
async function dashboard(req, res) {
try {
return res.json({
@@ -67,6 +101,7 @@ async function dashboard(req, res) {
posts: await posts.counts(),
users: await users.count(),
},
warnings: [await emailWarning()].filter(Boolean),
recent_activity: await activity.list({ limit: 10 }),
})
} catch (err) {

View File

@@ -12,7 +12,7 @@
// through toward another mount and 403 an editor on an unrelated route. Keep
// gates per-route in this file.
//
// GET /dashboard — stats overview, any staff role.
// GET /dashboard — stats overview + operator warnings, any staff role.
// PUT /site-mode — live ↔ maintenance, admin only.
//
// Neither is the audit log (/activity) nor the bot-scoring state
@@ -34,7 +34,7 @@ dashboardRouter.get(
// #swagger.tags = ['Admin · Dashboard']
// #swagger.summary = 'Dashboard summary counts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, warnings: { type: "array", description: "Operator warnings needing action; empty when there is nothing to say", items: { type: "object", properties: { code: { type: "string", example: "EMAIL_TRANSPORT_MIGRATION" }, message: { type: "string" }, href: { type: "string" } } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.dashboard,
)

View File

@@ -1,9 +1,13 @@
// Admin · Email — outbound mail delivery via Gmail OAuth2.
// Admin · Email — outbound mail delivery.
//
// Mounted at /api/v1/admin/email by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. The modern replacement for env SMTP: the
// refresh token is captured by the connect flow below and is write-only over
// this API (stored encrypted by utils/secretBox.js, never returned).
// `noindex, isLoggedIn, staffOnly`. The credential for the selected transport is
// captured by PUT /config below and is write-only over this API (stored encrypted
// by utils/secretBox.js, never returned).
//
// Four routes. `/connect/start` and `/connect/callback` were deleted with the
// Gmail OAuth2 flow in engagement Phase 1 (ENGAGEMENT.md §1.2a) — SMTP has no
// redirect to bounce through, so a credential form is the whole of it.
//
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
// the middleware chain each route carries is unchanged by the move.
@@ -21,9 +25,10 @@ const adminOnly = requireRole('admin')
emailRouter.get(
'/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Get email delivery config + status (admin only)'
// #swagger.summary = 'Get email delivery config, status and the transport catalog (admin only)'
// #swagger.description = 'Credentials are write-only: secret fields are never returned, only a per-field `secretsSet` flag. `transports` carries each registered transport&#39;s declared credential fields, which is what the admin form renders.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[200] = { description: 'Config (secrets stripped) + status + transport catalog', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
@@ -33,45 +38,32 @@ emailRouter.put(
'/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Update email delivery config (admin only)'
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
// #swagger.description = 'Set the transport, sender identity, credentials and enabled toggle. `credential` is a patch against the stored blob — a secret field submitted empty keeps its stored value. Enabling requires complete credentials and a sender address.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { transport: { type: "string", example: "smtp" }, senderEmail: { type: "string", format: "email" }, senderName: { type: "string" }, replyTo: { type: "string", format: "email" }, credential: { type: "object", additionalProperties: true }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[400] = { description: 'Unknown transport, or cannot enable without complete credentials and a sender address', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('transport').optional().isString().trim().isLength({ min: 1, max: 32 }),
body('senderEmail').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
body('replyTo').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
// The credential's SHAPE is the transport's to declare, so it is validated by
// the registry's sanitizer (which drops anything undeclared) rather than by a
// field list duplicated here that would drift the first time a transport is
// added. All this asserts is that it is an object at all.
body('credential').optional().isObject(),
body('enabled').optional().isBoolean(),
validate,
emailConfig.saveConfig,
)
emailRouter.get(
'/connect/start',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.connectStart,
)
emailRouter.get(
'/connect/callback',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
adminOnly,
emailConfig.connectCallback,
)
emailRouter.post(
'/test',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Send a test email (admin only)'
// #swagger.description = 'The real verification of the configuration — host, port, TLS mode, credentials, and whether the relay accepts the configured sender. Failures return a specific diagnostic.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
@@ -84,9 +76,9 @@ emailRouter.post(
emailRouter.post(
'/disconnect',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
// #swagger.summary = 'Clear the stored credentials and disable email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[200] = { description: 'Cleared config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,

View File

@@ -1,67 +1,37 @@
// ── Admin: outbound email configuration (Gmail OAuth2) ─────────────────────
// ── Admin: outbound email configuration ────────────────────────────────────
//
// Modern replacement for env-var SMTP. Sending goes through Gmail over OAuth2;
// the admin connects the mailbox with an in-app consent flow that captures a
// refresh token. We reuse the existing `google` SSO OAuth client (its id/secret)
// rather than a second app — so the only per-mailbox secret is the refresh token,
// stored AES-GCM-encrypted and write-only over this API (never returned).
// Email is configured here, not via env vars. The admin picks a registered mail
// transport and fills in the fields that transport declares; the whole set is
// stored as one AES-GCM-encrypted blob and is write-only over this API — secret
// fields are never returned, only a per-field "is it set" flag.
//
// The connect flow mirrors sso.controller.js: a signed httpOnly tx cookie carries
// the CSRF nonce + PKCE verifier across the redirect to Google and back. It differs
// only in scope (https://mail.google.com/ for SMTP XOAUTH2) and access_type=offline
// + prompt=consent, which guarantee a refresh token even on reconnect.
// Gmail OAuth2 and its consent flow were removed in engagement Phase 1
// (ENGAGEMENT.md §1.2a). What went with it: two routes, the `email_oauth_tx`
// signed cookie, the PKCE verifier and CSRF nonce plumbing, the
// `https://mail.google.com/` scope, and the borrowed `google` auth-providers
// client. That last one was a real coupling — an admin rotating the Google SSO
// secret silently broke outbound mail, with nothing on either screen relating the
// two — and removing it is one of the better side effects of the decision.
//
// **The form is driven by the transport's `credentialFields`, not by this file.**
// getConfig ships the declarations to the client, saveConfig hands the submitted
// body to the registry's sanitizer, and neither one names a field. Adding a
// transport is a registration, not an edit here.
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
const authProviders = require('../../../model/authProviders/authProviders.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
const GoogleProvider = require('../../../auth/providers/google.provider')
const ssoState = require('../../../auth/ssoState')
const token = require('../../../auth/token')
const { transports } = require('../../../engagement')
const log = require('../../../utils/logger')('admin')
// Gmail scope grants SMTP (XOAUTH2) access; openid+email let us read back which
// address was connected. The narrower gmail.send scope only works via the Gmail
// API, not SMTP, so we need the full-access scope here.
const EMAIL_SCOPE = 'https://mail.google.com/ openid email'
const TX_COOKIE = 'email_oauth_tx'
// Public base URL for the OAuth redirect_uri — same fallback pattern as
// sso.controller.js. Must be identical between start and callback.
function appBaseUrl(req) {
const configured = process.env.APP_BASE_URL
if (configured) return configured.replace(/\/+$/, '')
const derived = `${req.protocol}://${req.get('host')}`
log.warn('APP_BASE_URL not set — deriving email redirect_uri from the request', { derived })
return derived
}
function redirectUri(req) {
return `${appBaseUrl(req)}/api/v1/admin/email/connect/callback`
}
function txCookieOptions(req) {
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
}
// Front-end redirect targets after the callback resolves.
const CONNECTED_URL = '/admin/settings?email_connected=1'
const errorUrl = (code) => `/admin/settings?email_error=${code}`
// Load the Google OAuth client (id + decrypted secret) reused for email. Returns
// null when the google provider hasn't been configured with credentials yet.
async function googleClient() {
const row = await authProviders.getWithSecret('google')
if (!row || !row.client_id || !row.client_secret) return null
return { clientId: row.client_id, clientSecret: row.client_secret }
}
// GET /admin/email/config
async function getConfig(req, res) {
try {
const config = await emailConfig.getSafe()
// Surface whether the Google client email can borrow is configured, so the
// UI can explain why Connect is unavailable.
config.googleConfigured = Boolean(await googleClient())
// The transport catalog rides with the config so the client renders the
// credential form from the same declarations the server validates against.
config.transports = transports.describe()
return res.json(config)
} catch (err) {
log.error('emailConfig.getConfig', err)
@@ -69,23 +39,50 @@ async function getConfig(req, res) {
}
}
// PUT /admin/email/config — sender name + enabled toggle. Enabling requires a
// connected mailbox (a stored refresh token).
// PUT /admin/email/config — transport, sender identity, credentials, enabled.
//
// Enabling requires a complete credential AND a sender address, checked against
// the state as it will be AFTER this save rather than before it: the admin fills
// the whole form and ticks Enable in one submit, and refusing that because the
// credential was absent a moment ago would make the screen impossible to use.
async function saveConfig(req, res) {
const { senderName, enabled } = req.body
const { transport, senderEmail, senderName, replyTo, credential, enabled } = req.body
try {
const current = await emailConfig.getSafe()
if (enabled && !current.hasRefreshToken) {
return res.status(400).json({ message: 'Connect a Gmail account before enabling email.' })
const nextTransport = transport === undefined ? current.transport : transport
if (!transports.has(nextTransport)) {
return res.status(400).json({ message: `Unknown mail transport "${nextTransport}".` })
}
const saved = await emailConfig.save({
transport: transport !== undefined ? nextTransport : undefined,
senderEmail: senderEmail !== undefined ? senderEmail || null : undefined,
senderName: senderName !== undefined ? senderName || null : undefined,
enabled,
replyTo: replyTo !== undefined ? replyTo || null : undefined,
credential,
// Enabling is applied only once the saved row can actually support it, so
// the response tells the truth about what is stored rather than echoing the
// request. The re-read below is what decides.
enabled: enabled === undefined ? undefined : Boolean(enabled),
updatedBy: req.user.id,
})
saved.googleConfigured = Boolean(await googleClient())
await activity.log({ req, action: 'email.config.update', detail: { enabled: saved.enabled } })
log.info('email config updated', { by: req.user.username, enabled: saved.enabled })
if (saved.enabled && !(saved.hasCredential && saved.senderEmail)) {
const reverted = await emailConfig.save({ enabled: false, updatedBy: req.user.id })
reverted.transports = transports.describe()
return res.status(400).json({
message: 'Add complete credentials and a sender address before enabling email.',
config: reverted,
})
}
saved.transports = transports.describe()
await activity.log({
req,
action: 'email.config.update',
detail: { transport: saved.transport, enabled: saved.enabled, hasCredential: saved.hasCredential },
})
log.info('email config updated', { by: req.user.username, transport: saved.transport, enabled: saved.enabled })
return res.json(saved)
} catch (err) {
log.error('emailConfig.saveConfig', err)
@@ -93,96 +90,14 @@ async function saveConfig(req, res) {
}
}
// GET /admin/email/connect/start — returns { url } for the browser to navigate to.
async function connectStart(req, res) {
try {
const client = await googleClient()
if (!client) {
return res.status(400).json({
message: 'Configure the Google authentication provider (client id + secret) before connecting email.',
})
}
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
const tx = ssoState.createTx({ flow: 'email' })
res.cookie(TX_COOKIE, tx.txToken, txCookieOptions(req))
const params = new URLSearchParams({
client_id: client.clientId,
redirect_uri: redirectUri(req),
response_type: 'code',
scope: EMAIL_SCOPE,
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
state: tx.nonce,
code_challenge: tx.codeChallenge,
code_challenge_method: 'S256',
})
const url = `${provider.authEndpoint()}?${params.toString()}`
return res.json({ url })
} catch (err) {
log.error('emailConfig.connectStart', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/email/connect/callback — exchange the code, capture the refresh
// token + connected address, store encrypted, and redirect back to Settings.
async function connectCallback(req, res) {
const txToken = req.cookies && req.cookies[TX_COOKIE]
const { code, state, error: oauthError } = req.query
res.clearCookie(TX_COOKIE, token.cookieOptions(req)) // single-use
if (oauthError) {
log.warn('email connect: provider returned error', { error: String(oauthError).slice(0, 60) })
return res.redirect(errorUrl('denied'))
}
const tx = ssoState.verifyTx(txToken, state)
if (!tx || tx.flow !== 'email' || !code) {
log.warn('email connect: bad state')
return res.redirect(errorUrl('bad_state'))
}
try {
const client = await googleClient()
if (!client) return res.redirect(errorUrl('no_client'))
const provider = new GoogleProvider({ clientId: client.clientId, clientSecret: client.clientSecret })
const tokenSet = await provider.exchangeCode({
code,
redirectUri: redirectUri(req),
codeVerifier: tx.verifier,
})
if (!tokenSet.refresh_token) {
// Google only returns a refresh token when it hasn't already granted one
// for this client+scope. prompt=consent should force it; if it's still
// missing the admin can revoke the app's access and retry.
log.warn('email connect: no refresh_token returned')
return res.redirect(errorUrl('no_refresh_token'))
}
const profile = await provider.getUserProfile(tokenSet.access_token)
const senderEmail = profile.email || null
if (!senderEmail) return res.redirect(errorUrl('no_email'))
await emailConfig.save({
senderEmail,
refreshToken: tokenSet.refresh_token,
enabled: true,
status: 'connected',
statusDetail: 'Connected',
updatedBy: req.user.id,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Connected', lastVerifiedAt: new Date() })
await activity.log({ req, action: 'email.connect', detail: { senderEmail } })
log.info('email connected', { senderEmail, by: req.user.username })
return res.redirect(CONNECTED_URL)
} catch (err) {
log.error('emailConfig.connectCallback', err)
return res.redirect(errorUrl('error'))
}
}
// POST /admin/email/test — send a test message (to the given address, or the
// contact recipient by default).
//
// This is the only verification the configuration gets. Under the removed consent
// flow the sending address came back from Google and was guaranteed to belong to
// the credential; an operator-typed sender the relay will not accept is a silent
// deliverability failure, so mailer.describeSendError puts the sender in the
// message and this hands it through verbatim.
async function testSend(req, res) {
try {
const result = await mailer.sendTest(req.body.to)
@@ -198,9 +113,9 @@ async function testSend(req, res) {
async function disconnect(req, res) {
try {
const config = await emailConfig.disconnect(req.user.id)
config.googleConfigured = Boolean(await googleClient())
config.transports = transports.describe()
await activity.log({ req, action: 'email.disconnect' })
log.info('email disconnected', { by: req.user.username })
log.info('email credentials cleared', { by: req.user.username })
return res.json(config)
} catch (err) {
log.error('emailConfig.disconnect', err)
@@ -208,4 +123,4 @@ async function disconnect(req, res) {
}
}
module.exports = { getConfig, saveConfig, connectStart, connectCallback, testSend, disconnect }
module.exports = { getConfig, saveConfig, testSend, disconnect }