feat(email): provider-neutral email service — Google Workspace (Gmail API) + dev provider

- 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.
This commit is contained in:
2026-08-25 14:42:01 -05:00
parent 5183a6af09
commit 9d382c95c5
5 changed files with 407 additions and 0 deletions
+104
View File
@@ -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 };