diff --git a/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md b/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md index fd65dd2..0407f56 100644 --- a/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md +++ b/docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md @@ -1,7 +1,9 @@ # Phase 5C — Enrollment Operations & Communications **Status: PHASE 5C COMPLETE. ENROLLMENT COMMUNICATIONS COMPLETE. STUDENT ACTIVATION PIPELINE READY.** -**LIVE GOOGLE WORKSPACE DELIVERY VALIDATION PENDING** — see §14. +**LIVE GOOGLE WORKSPACE DELIVERY VALIDATED** — real activation and password-reset emails +delivered end to end via `academy@rootnodeacademy.com` (a Workspace alias), DKIM/SPF passing, +inbox delivery confirmed. 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 @@ -221,8 +223,26 @@ is exhausted, then `failed` too. Never an infinite loop; every attempt is persis 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). +**Live Google Workspace delivery has been validated for real**, both in isolation +(`infra/scripts/gws-email-test.js`, one message, DKIM/SPF passing, delivered) and through the +full genuine flow: a real student created, enrolled, receiving a real activation email, setting +their password from the real link, logging in, seeing their enrolled program, requesting and +receiving a real password-reset email, and completing the reset — every step against the live +Platform API, `EMAIL_PROVIDER=gws`, nothing simulated. + +**One real Google Workspace limitation found and handled:** newly-authorized domain-wide +delegation does not take effect instantly across all of Google's auth infrastructure — the +identical token request succeeded, then failed with `unauthorized_client`, then succeeded again, +over the span of about a minute, before settling. This is Google-side propagation delay, not a +configuration error, and it self-resolved. `gws-provider.js` now classifies `unauthorized_client` +as a *temporary* failure (retried with the existing bounded backoff) rather than permanent — +this is exactly the failure mode that backoff exists for, and it means a real deployment doesn't +need a human to notice and manually retry during that propagation window. See +`docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md`'s troubleshooting section. + +**A second real-world detail found during setup, not a bug:** the sending address +(`academy@rootnodeacademy.com`) is a Workspace *alias* of an existing account rather than its +own paid seat — a deliberate cost-saving choice. Domain-wide delegation authorizes a real +account, not an arbitrary alias string, so `GOOGLE_DELEGATED_USER` (the alias's real owner) was +added as a setting distinct from `EMAIL_FROM_ADDRESS` (the visible sending address) — see §2 and +the runbook §5. diff --git a/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md b/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md index 2dc878f..3f07feb 100644 --- a/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md +++ b/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md @@ -171,7 +171,7 @@ individual key — rotating keys does not require re-authorizing delegation. | Symptom | Likely cause | |---|---| | Every send fails with `gws_not_configured` | `GOOGLE_SERVICE_ACCOUNT_JSON` or `EMAIL_FROM_ADDRESS` unset/malformed in the Platform API's environment. | -| `gws_auth_failed` | Domain-wide delegation not authorized for this Client ID/scope, or the service account key was deleted/revoked. Re-check §4. | +| `gws_auth_failed` | Domain-wide delegation not authorized for this Client ID/scope, or the service account key was deleted/revoked. Re-check §4. **If this happens intermittently right after newly authorizing delegation** (the exact same request succeeds, then fails with `unauthorized_client`, then succeeds again, over roughly a minute) — that's Google's own auth infrastructure propagation delay, not a config error. It self-resolves; the notification worker's existing retry/backoff already absorbs it (`gws-provider.js` classifies `unauthorized_client` as retryable), so no action is needed beyond waiting. | | `gws_http_403` | The delegated `sub` (EMAIL_FROM_ADDRESS) isn't a real mailbox in this Workspace domain, or the Gmail API isn't enabled on the GCP project (§3.2). | | `gws_rate_limited` (HTTP 429) | Gmail API per-user sending limits hit — see §12. The notification worker retries this automatically with backoff. | | Mail sends successfully but lands in spam | SPF/DKIM/DMARC (§6) not fully configured/passing yet — this is a deliverability issue, not an application error. | diff --git a/platform/api/src/email/gws-provider.js b/platform/api/src/email/gws-provider.js index 535d80d..58c493c 100644 --- a/platform/api/src/email/gws-provider.js +++ b/platform/api/src/email/gws-provider.js @@ -71,7 +71,15 @@ async function getAccessToken() { 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'; + // 'unauthorized_client' is retried, not treated as permanent — real-world observation + // during Phase 5C setup: freshly-authorized domain-wide delegation can take a few minutes + // to propagate across Google's auth infrastructure, producing this EXACT error + // intermittently (the identical request succeeded, then failed, then failed again, over a + // span of about a minute) even though the delegation is correctly configured. A config + // that's genuinely wrong still ends up 'failed' once MAX_ATTEMPTS is exhausted — this only + // buys the propagation-delay case a real chance to self-resolve instead of giving up on + // the very first attempt. + e.errorClass = (res.status >= 500 || body.error === 'unauthorized_client') ? 'temporary' : 'permanent'; e.errorCode = 'gws_auth_failed'; throw e; } diff --git a/platform/docker-compose.dev.yml b/platform/docker-compose.dev.yml index 1afa8f3..1a31e6b 100644 --- a/platform/docker-compose.dev.yml +++ b/platform/docker-compose.dev.yml @@ -80,6 +80,13 @@ services: # network call no matter what recipient it's given. PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200} EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy} + # Empty/unset by default (fail-safe: dev provider, no real email — see email/provider.js). + # Set all four in platform/.env only for a deliberate one-off live-delivery test; unset + # again afterward so routine dev/test runs never risk a real send. + EMAIL_PROVIDER: ${EMAIL_PROVIDER:-} + GOOGLE_SERVICE_ACCOUNT_JSON: ${GOOGLE_SERVICE_ACCOUNT_JSON:-} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + GOOGLE_DELEGATED_USER: ${GOOGLE_DELEGATED_USER:-} PLATFORM_API_PORT: "4300" # No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via # `npm run bootstrap-owner` for the first owner and the admin portal after that.