# Phase 5C — Enrollment Operations & Communications **Status: PHASE 5C COMPLETE. ENROLLMENT COMMUNICATIONS COMPLETE. STUDENT ACTIVATION PIPELINE READY.** **LIVE GOOGLE WORKSPACE DELIVERY VALIDATION PENDING** — see §14. Phase 5B built the enrollment/entitlement model and the website integration boundary. What was still missing for a real student: nobody ever told them. This phase closes that gap — real email delivery for activation, enrollment confirmation, and password reset — plus finally completing the production password-reset delivery Phase 5A explicitly left unfinished. ## 1. Architecture: provider-neutral by design ``` Enrollment/Auth code (integration-routes.js, admin-routes.js, student-auth-routes.js) ↓ (mint token if needed, then) notifications.enqueue() notification_events row, committed in the SAME request — no network I/O yet ↓ notification-worker.js (a setInterval poller, same pattern as sessions.js's sweeper) ↓ email/provider.js — EMAIL_PROVIDER env var picks the implementation ↓ email/dev-provider.js (default) OR email/gws-provider.js (never a real network call) (Gmail API, service account) ``` No enrollment/auth code anywhere imports a concrete provider. Swapping Google Workspace for a different provider later means writing one new file under `platform/api/src/email/` and changing `EMAIL_PROVIDER` — nothing else changes. ## 2. Google Workspace delivery mechanism: Gmail API + service account domain-wide delegation **Selected mechanism:** the Gmail API's `users.messages.send`, authenticated as a Google Cloud service account using Workspace domain-wide delegation to impersonate one dedicated sending mailbox. **Why, not SMTP or a human login:** - **No human password, ever.** Basic SMTP auth (or an "app password") means storing credentials that log in as an actual person's Workspace account — anyone with that value could read that mailbox's mail, change its settings, or use it to send as that person. A service account has no login of its own and no inbox; domain-wide delegation grants it exactly one narrow OAuth scope (`gmail.send`) against one specific mailbox, nothing more. - **Least privilege.** `gmail.send` cannot read mail, cannot manage the mailbox, cannot act as any address other than the one configured. Compare to SMTP relay, which typically wants either a static allow-listed sending IP (fragile for infrastructure that isn't pinned yet) or a Workspace login. - **This is Google's own documented pattern** for "an application needs to send mail as a specific Workspace address" — see `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` for the exact setup steps and links. **No `googleapis`/`google-auth-library` dependency.** The JWT-bearer OAuth flow (RS256-sign a short JWT, exchange it for an access token, POST one REST call) is ~120 lines using only Node's built-in `crypto` and `fetch` — consistent with this codebase's established "hand-roll before adding a dependency" convention (CSRF tokens, Prometheus exposition, and CSV parsing all do the same). See `platform/api/src/email/gws-provider.js`. ## 3. RootNode email identity `EMAIL_FROM_NAME` / `EMAIL_FROM_ADDRESS` / `EMAIL_REPLY_TO` — all environment-configured, no default mailbox address baked into the code (`EMAIL_FROM_NAME` defaults to the display string "RootNode Academy"; `EMAIL_FROM_ADDRESS` has no default at all — sending fails closed with a clean `gws_not_configured` error until it's set, rather than guessing a real mailbox that doesn't exist in this repository's configuration). `EMAIL_FROM_ADDRESS` doubles as the Gmail API adapter's delegated-user (`sub`) claim — it must be a real mailbox in the Workspace domain, authorized for delegation (see the runbook). DNS/domain requirements before any of this can send real mail in production — SPF, DKIM, DMARC, and Workspace's own sender-authorization step — are **entirely external to this repository** and are documented, not performed, in `docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md` (marked `REQUIRES GOOGLE WORKSPACE ADMIN ACTION`). ## 4. Notification model `notification_events` (migration `021_notification_events.sql`): `student_id` (required — every notification this phase sends is about a specific student), `enrollment_id` (nullable — password-reset/changed notifications have none), `notification_type` (`activation`/`enrollment_confirmation`/`password_reset`/`password_changed`), `recipient_email` (a snapshot, not a live join — see §10), `status` (`queued`→`sending`→`sent`/`failed`, or `cancelled`, unused this phase but reserved), `attempt_count`/`next_attempt_at` for bounded exponential backoff, `last_error_code` (a classified string, never a raw stack trace), and `metadata` JSONB. **Privacy note on `recipient_email`:** duplicates PII already stored elsewhere (`students.email` or an enrollment's `contact_email`) purely so the worker and any later delivery audit don't need to reconstruct "what address would this have gone to" after the student record potentially changes. Same sensitivity class as data already in this database — never a new exposure, and never returned unmasked through any admin API (`notifications.js`'s `maskEmail`/`toAdminView` are the only shapes any route returns). ## 5. The transient-plaintext-token design (read this before touching token handling) Phase 5A's invariant — only a HASH of an activation/reset token is ever persisted long-term (`students.credential_token_hash`) — is unchanged and non-negotiable. But an activation/reset *email* needs the real token to build a working link, and the whole point of this phase is that the actual send happens later, asynchronously, in the worker — not in the request that originally minted the token. The resolution: the triggering request mints the token exactly as Phase 5A already does (synchronously, in-request), and hands the plaintext to `notifications.enqueue()`, which stores it in that one row's `metadata.token` **only until the row reaches a terminal state** (`markSent`/`markFailedOrRetry` both scrub it — see their comments in `notifications.js`). This is a deliberate, narrow, documented exception for the row that exists specifically to deliver the plaintext once — not a second credential store, and never returned by any admin API regardless of whether it's still present (`toAdminView` never includes `metadata` at all). Activation notifications from a **new enrollment** are the one case where no token is minted ahead of time and handed in — `notifyForEnrollment()` mints it itself, at enqueue time, because there's no other synchronous caller already holding one. Everywhere else (admin create/convert/ reset-password, self-service forgot-password, admin resend), the existing Phase 5A minting call site is reused unchanged and its plaintext is simply also passed to `enqueue()`. ## 6. Activation flow Reuses Phase 5A's activation token mechanism entirely — no competing invitation-token system was built. `issueCredentialToken` already unconditionally invalidates any previous outstanding token on mint (Phase 5A), which means **"resend" and "regenerate" are mechanically the same operation**: there is never a moment with two simultaneously valid activation links, so a second button that did something different would have nothing different to do. `POST /api/admin/ students/:id/resend-activation` is the one action that covers both. ``` Enrollment event/admin enrollment → student matched/created → credentials already active? → enrollment_confirmation (§7) → else → issueCredentialToken('activation') → notifications.enqueue() → worker delivers via the configured provider → student opens the link → existing Set Password flow (Phase 5A, unchanged) → token consumed atomically with setting the password → account active, student can log in ``` ## 7. Enrollment confirmation flow Fires whenever a **new enrollment** is created (integration event or admin manual enroll) for a student who **already has credentials** (`students.password_hash IS NOT NULL`). No token, no link beyond "go sign in" — `notifyForEnrollment()` (shared by both creation paths) is the single decision point, so a website-triggered and an admin-triggered enrollment behave identically. ## 8. Password reset flow — now actually delivered Phase 5A's `POST /api/student/request-password-reset` already minted a token and, only outside production, echoed it back for local/test use. Phase 5C adds exactly one thing: the same minted token is now also handed to `notifications.enqueue()`, so a real email goes out. Every other behavior is unchanged: the response is the same generic anti-enumeration message regardless of whether the account exists, is inactive, or was never activated (this phase additionally excludes never-activated accounts, a real fix — see §13); production never returns the token, dev/test still can. `POST /api/student/reset-password` (consuming the token) now also enqueues a `password_changed` security-notice email once the new password is set. ## 9. Password-changed / security notice Fires after both `POST /api/student/change-password` (self-service) and `POST /api/student/ reset-password` (token consumption) succeed — no token, no link, purely "this happened, if it wasn't you here's what to do." ## 10. Minor/no-email communication design Phase 5A/5B's minor-safe model (a student may have `login_identifier` with no `email` at all) is preserved exactly. `enrollments.metadata.contact_email` (an existing JSONB column, not a new migration) holds an OPTIONAL "who to notify about THIS enrollment" address — a guardian's email collected at checkout, for example. **It is never written to `students.email`** — that field stays reserved for the student's own identity/login credential; a guardian is not a student. `enrollment.js`'s `enrollmentContactEmail()` is the one accessor with that one meaning; recipient resolution (`notifications.js`'s `resolveRecipient`) tries the student's own email first, falls back to the enrollment's contact email only for enrollment-scoped notification types, and correctly has no fallback at all for password-reset/changed (which require the student to already be able to log in themselves). This is deliberately transitional — not a guardian account, not a linked identity, no relationship table — and does not block a future real Guardian Portal from being built on top of it later. ## 11. Website → enrollment → communication `POST /api/integrations/enrollments` (Phase 5B, unchanged) now also decides and enqueues the right notification, but **only when its own enrollment-creation call reports `created: true`** (a genuinely new enrollment, not an idempotent replay). This is the entire duplicate-webhook safety net for notifications: since Phase 5B's DB-level idempotency already guarantees at most one caller ever sees `created: true` for a given event/race, at most one notification is ever queued, with zero new dedup mechanism needed. ## 12. Retry/failure handling `markFailedOrRetry` classifies every provider error as `temporary` or `permanent` (both providers' `send()` throw an `Error` with `.errorClass`/`.errorCode` set). Permanent failures (bad recipient, missing/invalid Google config) go straight to `failed` on the first attempt — retrying can't fix them. Temporary failures (network/provider hiccups, rate limiting) requeue with bounded exponential backoff (30s base, doubling, capped at 1 hour) until `MAX_ATTEMPTS` (5) is exhausted, then `failed` too. Never an infinite loop; every attempt is persisted (`attempt_count`, `last_error_code`). ## 13. Real bugs found and fixed - `request-password-reset` would previously mint a `reset` token for a student who had **never been activated** (`password_hash IS NULL`) — a "reset" for an account with no password to reset doesn't make semantic sense, and would have sent a confusing "reset your password" email to someone who needs activation, not reset. Fixed: that branch now also checks `student.password_hash` and falls through to the same generic anti-enumeration response, exactly as if the account didn't exist. - **HTML injection in email templates.** `displayName` (which can come straight from a website integration event's `student.display_name` — genuinely external, caller-controlled text, not admin-typed) was interpolated directly into the HTML email body with no escaping. `templates.js` now escapes every user-controlled value before it reaches HTML (`escapeHtml`); the plain-text bodies are correctly left unescaped, since there's no markup there to break. Both fixes are regression-tested in `e2e-phase5c.js`. ## 14. Testing - `platform/test/e2e-phase5c.js` (28 checks): activation vs enrollment-confirmation decision, duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry classification, resend/regenerate token invalidation, activation/reset single-use and expiration, production token-secrecy gating (a real container recreate with `COOKIE_SECURE=1`), minor/contact-email resolution, RBAC scoping, metrics/audit hygiene, and a real Platform API restart. - `infra/scripts/drills/test-phase5c-enrollment-activation.js` (10 checks): one continuous story — website enrollment → activation email rendered and its URL extracted exactly like a real inbox click → password set → real Lab launched → real scenario completed → a second enrollment gets confirmation not activation → full password-reset round trip with session revocation → the minor/contact-email path. - Both against a live `docker-compose.dev.yml` stack, using the deterministic dev email provider exclusively — **no test in this phase ever makes a real network call to Google or anyone else.** - Migration safety proven explicitly against a fresh Postgres taken through every prior phase's migrations, seeded with realistic Phase 5B data (credentialed student, minor/no-email student, cohort, enrollment with a contact-email, progress, attempts, achievements, classroom history), then this phase's migration applied — every prior record intact; a new notification correctly links to the pre-existing student/enrollment; re-running the migration set is a no-op. **Live Google Workspace delivery has not been validated** — no service account/domain-wide delegation exists yet for this RootNode Workspace domain. The adapter (`gws-provider.js`) is fully implemented and unit-testable at the code level; `infra/scripts/gws-email-test.js` is ready to run the moment credentials exist (opt-in, `RUN_GWS_EMAIL_TEST=true`, sends exactly one message to one operator-configured test address — see the runbook).