- PHASE5C_ENROLLMENT_COMMUNICATIONS.md: the full design doc. - RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md: every REQUIRES GOOGLE WORKSPACE ADMIN ACTION step (mailbox, service account, domain-wide delegation scoped to gmail.send only, SPF/DKIM/DMARC), environment variables, testing procedure, production secret handling, rotation, revocation, troubleshooting, quotas. - RUNBOOK_STUDENT_ACTIVATION.md: day-to-day operation of the pipeline from /admin. - WEBSITE_ENROLLMENT_INTEGRATION.md: explicit website-vs-RootNode responsibility boundary, the new contact_email field, and closes the 'RootNode doesn't send activation email yet' known limitation this doc previously documented. - RUNBOOK_SELF_PACED_ENROLLMENT.md, RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md: point at the new automatic-email behavior and the Resend activation control. - REMOTE_AND_SELF_PACED_ARCHITECTURE.md, ENGINEERING_SOP.md: close the enrollment- communication gap; new ENGINEERING_SOP §16. - PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md: cross-references and a Phase 5C production-validation section.
14 KiB
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.sendcannot 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.mdfor 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-resetwould previously mint aresettoken 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 checksstudent.password_hashand 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'sstudent.display_name— genuinely external, caller-controlled text, not admin-typed) was interpolated directly into the HTML email body with no escaping.templates.jsnow 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 ine2e-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 withCOOKIE_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.ymlstack, 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).