From 9d382c95c50646eb873744703fcee8ee98edfbb0 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 14:42:01 -0500 Subject: [PATCH] =?UTF-8?q?feat(email):=20provider-neutral=20email=20servi?= =?UTF-8?q?ce=20=E2=80=94=20Google=20Workspace=20(Gmail=20API)=20+=20dev?= =?UTF-8?q?=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email/provider.js: the seam. EMAIL_PROVIDER unset always defaults to the dev provider, regardless of any other setting — misconfiguration means email silently doesn't send for real, never that a dev/test run accidentally reaches a real student. - email/gws-provider.js: Gmail API users.messages.send via a service account with Workspace domain-wide delegation, impersonating one dedicated mailbox. No human password, no SMTP basic auth, no googleapis/google-auth-library dependency — a hand-rolled RS256 JWT-bearer OAuth exchange + one REST call (~120 lines), matching this codebase's established hand-roll-before-adding-a-dependency convention. - email/dev-provider.js: never makes a network call, returns the rendered message back to the caller for dev/test inspection. Supports magic +forcetempfail/+forcepermfail recipient markers so the permanent test suite can deterministically exercise retry/failure classification without needing a real flaky provider. - email/templates.js: activation/enrollment-confirmation/password-reset/password-changed, HTML + plain-text, no external images/tracking/JS. Every interpolated value is HTML-escaped in the HTML body (a real injection finding — display_name can come straight from a website integration event). - notification-worker.js: a setInterval poller (same pattern as sessions.js's sweeper), claims via notifications.js, renders via templates.js, builds activation/reset URLs from PUBLIC_APP_ORIGIN, stores a dev-only preview (never in production) so tests can extract the real URL without a mailbox. --- platform/api/src/email/dev-provider.js | 35 +++++++ platform/api/src/email/gws-provider.js | 114 ++++++++++++++++++++ platform/api/src/email/provider.js | 20 ++++ platform/api/src/email/templates.js | 134 ++++++++++++++++++++++++ platform/api/src/notification-worker.js | 104 ++++++++++++++++++ 5 files changed, 407 insertions(+) create mode 100644 platform/api/src/email/dev-provider.js create mode 100644 platform/api/src/email/gws-provider.js create mode 100644 platform/api/src/email/provider.js create mode 100644 platform/api/src/email/templates.js create mode 100644 platform/api/src/notification-worker.js diff --git a/platform/api/src/email/dev-provider.js b/platform/api/src/email/dev-provider.js new file mode 100644 index 0000000..cc264f0 --- /dev/null +++ b/platform/api/src/email/dev-provider.js @@ -0,0 +1,35 @@ +// Phase 5C §20 — the deterministic development/test email provider. NEVER makes a network +// call, NEVER reaches a real inbox, no matter what recipient it's given — this is the fail-safe +// default (see provider.js) precisely so a dev machine or a CI run can never accidentally email +// a real student. Returns the fully rendered message back to the caller so +// notifications.js can stash a preview (dev/test only — see its isProduction gate, mirroring +// student-auth-routes.js's existing dev_reset_token pattern) for tests/admins to inspect the +// activation/reset URL without a real mailbox. + +async function send({ to, subject, text, html }) { + if (!to) { + const e = new Error('no recipient address'); + e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address'; + throw e; + } + // Deterministic failure-injection for the permanent test suite (e2e-phase5c.js) — magic + // recipient markers, the same pattern test payment sandboxes use for "this card always + // declines." Only recognized by THIS provider; a real GWS send never looks at the address + // this way, so there's no risk of this leaking into production behavior. + if (to.includes('+forcetempfail')) { + const e = new Error('simulated temporary provider failure'); e.errorClass = 'temporary'; e.errorCode = 'simulated_temporary_failure'; + throw e; + } + if (to.includes('+forcepermfail')) { + const e = new Error('simulated permanent provider failure'); e.errorClass = 'permanent'; e.errorCode = 'simulated_permanent_failure'; + throw e; + } + console.log(`[dev-email] to=${to} subject="${subject}"`); + return { + ok: true, + providerMessageId: `dev-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + preview: { to, subject, text, html }, + }; +} + +module.exports = { send }; diff --git a/platform/api/src/email/gws-provider.js b/platform/api/src/email/gws-provider.js new file mode 100644 index 0000000..5f49ac9 --- /dev/null +++ b/platform/api/src/email/gws-provider.js @@ -0,0 +1,114 @@ +// Phase 5C §2 — the Google Workspace adapter. Sends via the Gmail API +// (users.messages.send), authenticated as a Google Cloud service account using Workspace +// domain-wide delegation to impersonate a dedicated sending mailbox (EMAIL_FROM_ADDRESS). +// +// WHY this mechanism, not SMTP or a human login: Google's own guidance for a backend +// application sending mail as a specific Workspace address is a service account + domain-wide +// delegation + the Gmail API — no human password is ever involved, nothing is stored that +// could log in to the Workspace Admin Console or read the mailbox's other mail (the delegated +// scope here is exactly `gmail.send`, nothing broader), and it never touches or weakens the +// impersonated mailbox's own login credentials. Plain SMTP with a username/password (or an app +// password) was explicitly ruled out — see docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md §2 for the +// full comparison and Google Admin Console setup steps this requires (REQUIRES GOOGLE +// WORKSPACE ADMIN ACTION — cannot be completed from this repository). +// +// No googleapis/google-auth-library dependency — this is a small, auditable amount of code +// (RS256 JWT assertion -> OAuth2 token exchange -> one REST call), consistent with this +// codebase's existing "hand-roll before adding a dependency" convention (CSRF tokens, +// Prometheus exposition, and CSV parsing all do the same — see their own header comments). + +const crypto = require('crypto'); + +function configError(message) { + return Object.assign(new Error(message), { errorClass: 'permanent', errorCode: 'gws_not_configured' }); +} + +// GOOGLE_SERVICE_ACCOUNT_JSON holds the standard JSON key Google issues for a service account — +// an environment/secret-manager value, loaded fresh on every send (no key material cached to +// disk by this code). Never a file committed to git, never a human password. +function loadServiceAccount() { + const raw = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; + if (!raw) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not set'); + let parsed; + try { parsed = JSON.parse(raw); } catch { throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is not valid JSON'); } + if (!parsed.client_email || !parsed.private_key) throw configError('GOOGLE_SERVICE_ACCOUNT_JSON is missing client_email/private_key'); + return parsed; +} + +// A short-lived (1 hour) OAuth2 access token via the JWT-bearer grant — minted fresh per send +// rather than cached, since this codebase's send volume doesn't justify the added state a +// cache would need to get right (token refresh races, expiry skew). See RFC 7523 / Google's +// "Using OAuth 2.0 for Server to Server Applications" documentation for the exact flow this +// implements. +async function getAccessToken() { + const sa = loadServiceAccount(); + const impersonate = process.env.EMAIL_FROM_ADDRESS; + if (!impersonate) throw configError('EMAIL_FROM_ADDRESS is not set (required as the delegated-user "sub" claim)'); + + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ + iss: sa.client_email, sub: impersonate, scope: 'https://www.googleapis.com/auth/gmail.send', + aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600, + })).toString('base64url'); + const signature = crypto.createSign('RSA-SHA256').update(`${header}.${payload}`).sign(sa.private_key).toString('base64url'); + const assertion = `${header}.${payload}.${signature}`; + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const e = new Error(`Google OAuth token exchange failed: ${body.error_description || body.error || res.status}`); + e.errorClass = res.status >= 500 ? 'temporary' : 'permanent'; + e.errorCode = 'gws_auth_failed'; + throw e; + } + return body.access_token; +} + +// A minimal, correct-enough RFC 2822 multipart/alternative message (text + HTML) — no MIME +// library dependency for the same "small and auditable" reason as the rest of this file. +function buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html }) { + const boundary = `rootnode-${crypto.randomBytes(12).toString('hex')}`; + const headers = [ + `From: ${fromName} <${fromAddress}>`, + `To: ${to}`, + replyTo ? `Reply-To: ${replyTo}` : null, + `Subject: =?UTF-8?B?${Buffer.from(subject, 'utf8').toString('base64')}?=`, + 'MIME-Version: 1.0', + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ].filter(Boolean).join('\r\n'); + const body = [ + `--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', text, '', + `--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', '', html, '', + `--${boundary}--`, + ].join('\r\n'); + return `${headers}\r\n\r\n${body}`; +} + +async function send({ to, fromName, fromAddress, replyTo, subject, text, html }) { + if (!to) { + const e = new Error('no recipient address'); + e.errorClass = 'permanent'; e.errorCode = 'no_recipient_address'; + throw e; + } + const accessToken = await getAccessToken(); + const raw = Buffer.from(buildMimeMessage({ fromName, fromAddress, replyTo, to, subject, text, html })).toString('base64url'); + + const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', { + method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ raw }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const e = new Error(`Gmail API send failed: ${body.error?.message || res.status}`); + e.errorClass = res.status >= 500 || res.status === 429 ? 'temporary' : 'permanent'; + e.errorCode = res.status === 429 ? 'gws_rate_limited' : `gws_http_${res.status}`; + throw e; + } + return { ok: true, providerMessageId: body.id }; +} + +module.exports = { send }; diff --git a/platform/api/src/email/provider.js b/platform/api/src/email/provider.js new file mode 100644 index 0000000..15770b8 --- /dev/null +++ b/platform/api/src/email/provider.js @@ -0,0 +1,20 @@ +// Phase 5C §1 — the provider-neutral seam. Business logic (notifications.js, the worker) never +// imports a concrete provider directly; it asks getEmailProvider() for whichever one +// EMAIL_PROVIDER selects. Swapping Google Workspace for a different provider later never +// touches enrollment/auth/notification code — only this file's require() target changes. +// +// Fail-safe default: EMAIL_PROVIDER unset -> 'dev', REGARDLESS of any other environment +// setting. Misconfiguring this in a real deployment means email silently doesn't send for +// real, never that a dev/test run accidentally reaches a real student's inbox (§20's explicit +// safety requirement) — the failure direction is deliberately the safe one. +const EMAIL_PROVIDER = (process.env.EMAIL_PROVIDER || 'dev').toLowerCase(); + +function getEmailProvider() { + if (EMAIL_PROVIDER === 'gws') return require('./gws-provider'); + if (EMAIL_PROVIDER !== 'dev') { + console.error(`[email] Unknown EMAIL_PROVIDER "${EMAIL_PROVIDER}" — falling back to the dev provider (no real mail will send).`); + } + return require('./dev-provider'); +} + +module.exports = { getEmailProvider, EMAIL_PROVIDER }; diff --git a/platform/api/src/email/templates.js b/platform/api/src/email/templates.js new file mode 100644 index 0000000..3072b15 --- /dev/null +++ b/platform/api/src/email/templates.js @@ -0,0 +1,134 @@ +// Phase 5C §9 — RootNode-branded transactional email templates. Every template returns +// { subject, text, html }: plain-text is never a second-class fallback, both are written +// deliberately. No external images, no tracking pixels, no JavaScript, no marketing content — +// this module is transactional/operational only (§5's explicit boundary). +// +// escapeHtml matters here more than in most of this codebase's server-rendered HTML: displayName +// can come straight from a website integration event's `student.display_name` (§21 "no +// arbitrary email template injection" — this is genuinely external, caller-controlled text, not +// admin-typed), and programName from an admin-created program name. Every value interpolated +// into an HTML template goes through this first; the plain-text bodies deliberately do NOT +// escape (there's no markup to break there — escaping would just show a student literal "&"). + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +const BRAND = 'RootNode Academy'; +const GREEN = '#2ee6a6'; +const BG = '#0b0f0e'; +const TEXT = '#e8f5ef'; +const MUTED = '#8fa89c'; + +function wrap(bodyHtml) { + return ` + + +
+ + + + +
⎈ ${BRAND}
${bodyHtml}
+This is an automated message from ${BRAND}. If you weren't expecting it, you can safely ignore it — no action is required. +
+
+`; +} + +// href is always a URL this module itself built from a token/origin, never caller-supplied text +// — still escaped for attribute-safety hygiene, not because it's expected to contain markup. +function button(url, label) { + return `${label}`; +} + +// A. Account activation / welcome — §9A. Fires for a brand-new credentialed account OR an +// existing pilot/unconverted student gaining their first real credential, always via the same +// existing Phase 5A activation-token mechanism. +function activation({ displayName, programName, activationUrl, expiresInDays }) { + const subject = `Welcome to ${BRAND} — Activate Your Account`; + const safeName = escapeHtml(displayName); + const safeProgram = escapeHtml(programName); + const programLine = programName ? `You've been enrolled in ${safeProgram}. ` : ''; + const text = `Hi ${displayName}, + +You've been given access to ${BRAND}. ${programName ? `You've been enrolled in ${programName}. ` : ''}To get started, set your password using the link below: + +${activationUrl} + +This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in. + +If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above. + +— ${BRAND}`; + const html = wrap(` +

Hi ${safeName},

+

You've been given access to ${BRAND}. ${programLine}To get started, set your password:

+

${button(activationUrl, 'Activate Your Account')}

+

This link is valid for ${expiresInDays} day${expiresInDays === 1 ? '' : 's'} and can only be used once. Setting your password activates your account and signs you in.

+

If you weren't expecting this, you can ignore this email — no account will be created without you clicking the link above.

`); + return { subject, text, html }; +} + +// B. Existing student, new program enrollment — §9B. No token, no link needed beyond "go sign in." +function enrollmentConfirmation({ displayName, programName, signInUrl }) { + const subject = `You're Enrolled — ${programName}`; + const safeName = escapeHtml(displayName); + const safeProgram = escapeHtml(programName); + const text = `Hi ${displayName}, + +You've been enrolled in ${programName} at ${BRAND}. Sign in to get started: + +${signInUrl} + +— ${BRAND}`; + const html = wrap(` +

Hi ${safeName},

+

You've been enrolled in ${safeProgram} at ${BRAND}.

+

${button(signInUrl, 'Sign In and Start Learning')}

`); + return { subject, text, html }; +} + +// C. Password reset — §9C. Same anti-enumeration principle as the API itself: this template +// only ever renders for an account that actually exists and requested it — the enumeration +// protection lives in whether this notification gets queued at all (student-auth-routes.js), +// not in the template. +function passwordReset({ displayName, resetUrl, expiresInMinutes }) { + const subject = `Reset Your ${BRAND} Password`; + const safeName = escapeHtml(displayName); + const text = `Hi ${displayName}, + +We received a request to reset your ${BRAND} password. Use the link below to set a new one: + +${resetUrl} + +This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above. + +— ${BRAND}`; + const html = wrap(` +

Hi ${safeName},

+

We received a request to reset your ${BRAND} password.

+

${button(resetUrl, 'Reset Your Password')}

+

This link is valid for ${expiresInMinutes} minutes and can only be used once. If you didn't request this, you can ignore this email — your password will not change unless you click the link above.

`); + return { subject, text, html }; +} + +// D. Password changed / security notice — §9D. No link at all, purely informational. +function passwordChanged({ displayName }) { + const subject = `Your ${BRAND} Password Was Changed`; + const safeName = escapeHtml(displayName); + const text = `Hi ${displayName}, + +Your ${BRAND} password was just changed. If this was you, no action is needed. + +If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution. + +— ${BRAND}`; + const html = wrap(` +

Hi ${safeName},

+

Your ${BRAND} password was just changed. If this was you, no action is needed.

+

If you didn't make this change, contact your instructor right away — your other sessions have already been signed out as a precaution.

`); + return { subject, text, html }; +} + +module.exports = { activation, enrollmentConfirmation, passwordReset, passwordChanged, escapeHtml }; diff --git a/platform/api/src/notification-worker.js b/platform/api/src/notification-worker.js new file mode 100644 index 0000000..5fe9e95 --- /dev/null +++ b/platform/api/src/notification-worker.js @@ -0,0 +1,104 @@ +// Phase 5C §14 — a simple durable worker, same pattern this codebase already uses for +// sessions.js's session sweeper and security.js's login-attempt sweeper: a plain setInterval +// poll against Postgres, no Kafka/RabbitMQ/Redis. notifications.js's claimNext() does the +// actual concurrency-safety work (FOR UPDATE SKIP LOCKED) — this file just calls it on a timer +// and hands the claimed row to the configured email provider. + +const notifications = require('./notifications'); +const templates = require('./email/templates'); +const { getEmailProvider } = require('./email/provider'); +const metrics = require('./metrics'); + +const POLL_MS = Number(process.env.NOTIFICATION_WORKER_POLL_MS) || 3000; + +function buildAppUrl(path) { + const origin = process.env.PUBLIC_APP_ORIGIN; + if (!origin) return null; + try { + const u = new URL(origin); + return `${u.origin}${path}`; + } catch { + return null; // malformed origin — treated as unconfigured, not a crash + } +} + +// Renders the right template for a queued row. Returns null (a permanent, non-retryable +// failure) if required config (PUBLIC_APP_ORIGIN for a token-bearing type) is missing — §7's +// "production should fail safely if required public-origin configuration is missing." +function renderFor(row) { + const displayName = row.metadata?.display_name || 'there'; + if (row.notification_type === 'activation') { + const url = buildAppUrl(`/?activate=${encodeURIComponent(row.metadata?.token || '')}`); + if (!url || !row.metadata?.token) return null; + return templates.activation({ displayName, programName: row.metadata?.program_name, activationUrl: url, expiresInDays: 7 }); + } + if (row.notification_type === 'password_reset') { + const url = buildAppUrl(`/?reset=${encodeURIComponent(row.metadata?.token || '')}`); + if (!url || !row.metadata?.token) return null; + return templates.passwordReset({ displayName, resetUrl: url, expiresInMinutes: 60 }); + } + if (row.notification_type === 'enrollment_confirmation') { + const url = buildAppUrl('/') || '/'; + return templates.enrollmentConfirmation({ displayName, programName: row.metadata?.program_name || 'your program', signInUrl: url }); + } + if (row.notification_type === 'password_changed') { + return templates.passwordChanged({ displayName }); + } + return null; +} + +async function processOne(pool) { + const row = await notifications.claimNext(pool); + if (!row) return false; + + const { EMAIL_PROVIDER } = require('./email/provider'); + const rendered = renderFor(row); + if (!rendered) { + await notifications.markFailedOrRetry(pool, row, { errorCode: 'template_or_config_error', retryable: false }); + metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'failed'); + return true; + } + + const provider = getEmailProvider(); + try { + const result = await provider.send({ + to: row.recipient_email, + fromName: process.env.EMAIL_FROM_NAME || 'RootNode Academy', + fromAddress: process.env.EMAIL_FROM_ADDRESS, + replyTo: process.env.EMAIL_REPLY_TO, + subject: rendered.subject, text: rendered.text, html: rendered.html, + }); + // §20 — a dev-only preview (never in production, regardless of provider) so a test/admin + // can inspect exactly what would have been sent, including the activation/reset URL, + // through the dedicated dev-only route — never through the normal admin notification view. + const isProduction = process.env.COOKIE_SECURE === '1'; + const devPreview = (!isProduction && result.preview) ? result.preview : null; + await notifications.markSent(pool, row.notification_id, result.providerMessageId, EMAIL_PROVIDER, devPreview); + metrics.recordNotificationAttempt(EMAIL_PROVIDER, 'sent'); + } catch (e) { + const retryable = e.errorClass !== 'permanent'; + const outcome = await notifications.markFailedOrRetry(pool, row, { errorCode: e.errorCode || 'unknown_error', retryable }); + metrics.recordNotificationAttempt(EMAIL_PROVIDER, outcome === 'retrying' ? 'retry' : 'failed'); + console.error(`[notification-worker] send failed (${row.notification_type}, ${outcome}): ${e.errorCode || e.message}`); + } + return true; +} + +function startNotificationWorker(pool) { + const tick = async () => { + try { + // Drain everything currently claimable before waiting out the next poll interval — + // keeps a burst (e.g. a CSV import's worth of activations) from trickling out one per + // POLL_MS when they could all go immediately. + while (await processOne(pool)) { /* keep draining */ } + } catch (e) { + console.error('[notification-worker] tick failed:', e.message); + } + }; + const handle = setInterval(tick, POLL_MS); + handle.unref(); // never keeps the process alive on its own, matching sessions.js's sweeper + tick(); // don't wait a full interval for the first pass + return handle; +} + +module.exports = { startNotificationWorker, processOne, renderFor, buildAppUrl };