Files
rootnode-academy/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md
T
ced4568 7baf7074e5 fix(email): support a delegated-mailbox alias distinct from the delegated Workspace account
Real-world case found during setup: the sending address ([email protected]) is an
alias of an existing Workspace user, not its own paid seat (a deliberate cost-saving choice).
Domain-wide delegation authorizes a real Workspace ACCOUNT, not an arbitrary alias string, so
using the alias directly as the delegated 'sub' claim would have been wrong. Added
GOOGLE_DELEGATED_USER (the account that actually owns the alias and was granted delegation) as
an optional override, falling back to EMAIL_FROM_ADDRESS unchanged when the sending address is
itself a primary account. EMAIL_FROM_ADDRESS remains the visible From: header in both cases.
Documented in docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md with a concrete example.
2026-08-25 17:38:59 -05:00

187 lines
12 KiB
Markdown

# Runbook: Google Workspace Email Configuration
Everything in this document happens OUTSIDE this repository, in the Google Admin Console and
Google Cloud Console, and is marked **REQUIRES GOOGLE WORKSPACE ADMIN ACTION**. Nothing here can
be scripted or automated from the codebase — Chase (or whoever holds Workspace super-admin) has
to do these steps by hand, once, before RootNode Academy can send real email.
See `docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md` §2 for why this mechanism was chosen over SMTP
or a human login.
## 1. Selected mechanism
**Gmail API (`users.messages.send`) via a Google Cloud service account using Workspace
domain-wide delegation**, impersonating one dedicated sending mailbox. No human password is
ever stored by RootNode. The delegated scope is exactly `https://www.googleapis.com/auth/
gmail.send` — nothing broader.
## 2. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the sending mailbox
Decide the address RootNode sends as — e.g. `[email protected]` or
`[email protected]`. This repository does not assume or hard-code one; it's whatever
you set `EMAIL_FROM_ADDRESS` to (§5).
1. In Google Admin Console (admin.google.com) → Directory → Users, create this mailbox as a
real Workspace user (or a Google Group with "Collaborative Inbox" if you'd rather route
replies to a shared team inbox — either works with this mechanism, but a Group needs its own
delegation setup; a plain user mailbox is simpler and is what this runbook assumes).
2. Consider giving it a strong, unique, never-reused password and enabling 2-Step Verification
on it anyway, even though RootNode's application will never use that password — this account
still exists as a real Workspace identity and should be secured like any other.
**If the sending address is an ALIAS of an existing account (a real cost-saving option — you
don't need a whole extra paid seat just for this):** that's fine and supported, but note it for
step 4 below. Domain-wide delegation authorizes the service account to act as a real Workspace
**user account**, not an arbitrary alias string — if `[email protected]` is an alias of,
say, `[email protected]`, the delegation in step 4 is granted for `chase@`'s account
(the alias's owner), and RootNode's config keeps the alias as the visible sending address
separately (see §5's `GOOGLE_DELEGATED_USER` vs. `EMAIL_FROM_ADDRESS`). Gmail already lets any
account send as its own verified aliases, so this works cleanly — you just need both pieces of
information (the alias, and which account owns it) when configuring RootNode.
## 3. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: create the service account (Google Cloud Console)
1. In Google Cloud Console (console.cloud.google.com), create or choose a GCP project associated
with your Workspace organization.
2. Enable the **Gmail API** for that project (APIs & Services → Library → search "Gmail API" →
Enable).
3. IAM & Admin → Service Accounts → Create Service Account. Name it something identifiable
(e.g. `rootnode-academy-mailer`). No project-level IAM roles are needed for this — it only
ever calls the Gmail API as the impersonated user, not as itself.
4. On that service account, create a JSON key (Keys → Add Key → Create new key → JSON). This
downloads a file containing `client_email` and `private_key` — **this is the sensitive
credential**, treat it exactly like a password (see §8).
5. Copy the service account's **Client ID** (a long numeric string, on the service account's
detail page) — needed for the next step.
## 4. REQUIRES GOOGLE WORKSPACE ADMIN ACTION: authorize domain-wide delegation
1. Google Admin Console → Security → Access and data control → API controls → Domain-wide
Delegation → Add new.
2. Client ID: the numeric ID from step 3.5 above.
3. OAuth Scopes: `https://www.googleapis.com/auth/gmail.send` — **only this scope**. Do not add
broader Gmail scopes (readonly, modify, etc.) — RootNode's adapter never needs them and
granting them would violate least-privilege for no benefit.
4. Authorize.
This grants the service account permission to act as **one specific Workspace user account**.
If the sending address is that account's own primary login, that's the account. If the sending
address is an ALIAS (§2), this authorization is inherently for the alias's owner account — there
is no separate "authorize an alias" step, because delegation only ever targets real accounts.
## 5. RootNode-side environment variables
Set these on the Platform API's environment (production: real secret storage — see §8; local
dev: `platform/.env`, already gitignored):
| Variable | Value | Notes |
|---|---|---|
| `EMAIL_PROVIDER` | `gws` | Unset/anything else defaults to the dev provider — never real mail. |
| `GOOGLE_SERVICE_ACCOUNT_JSON` | the full JSON key file's contents, as one string | The sensitive credential — see §8. |
| `EMAIL_FROM_ADDRESS` | the address students see mail arrive from, e.g. `[email protected]` | Always the visible `From:` header. |
| `GOOGLE_DELEGATED_USER` | **only needed if `EMAIL_FROM_ADDRESS` is an alias** — the real account that alias belongs to, e.g. `[email protected]` | This is what domain-wide delegation actually authorized in step 4. If `EMAIL_FROM_ADDRESS` is itself a primary account, leave this unset — the code falls back to `EMAIL_FROM_ADDRESS` automatically. |
| `EMAIL_FROM_NAME` | e.g. `RootNode Academy` | Display name only, not sensitive. |
| `EMAIL_REPLY_TO` | optional, e.g. `[email protected]` | Where a student's reply actually goes, if different from the sending address. |
| `PUBLIC_APP_ORIGIN` | e.g. `https://learn.rootnodeacademy.com` | The origin activation/reset links are built from (§7 of the Phase 5C authorization) — required for any token-bearing email to render a working link. |
**Concrete example matching the alias setup described above:** `[email protected]`
(what students see), `[email protected]` (whose account actually
owns that alias and was granted delegation in step 4).
## 6. DNS: SPF, DKIM, DMARC (REQUIRES DNS ADMIN ACTION — not performed from this repository)
Google Workspace's own documentation is authoritative here; this is a summary of what must
exist before mail sent via the Gmail API from a Workspace mailbox reliably lands in inboxes
instead of spam:
- **SPF**: your domain's TXT record must include Google's SPF include
(`include:_spf.google.com`) if Workspace is your MX provider — check Admin Console → Apps →
Google Workspace → Gmail → Authenticate email for the exact current record Google recommends
for your domain.
- **DKIM**: Admin Console → Apps → Google Workspace → Gmail → Authenticate email → generate a
DKIM key for the domain, then publish the TXT record it gives you, then return to that page
and turn DKIM signing ON. Gmail API sends from a Workspace mailbox are signed the same way
regular Gmail sends are once this is on.
- **DMARC**: publish a `_dmarc` TXT record (start with `p=none` to monitor before enforcing) once
SPF and DKIM both pass consistently.
**Do not change DNS from this repository or these instructions** — this is exactly the kind of
external, admin-console/DNS-provider action that stays outside the codebase. Verify current
exact record values in Google's own Workspace admin help before publishing anything.
## 7. Testing procedure
**Local/dev — no live credentials needed at all.** `EMAIL_PROVIDER` unset (or anything other
than `gws`) uses the deterministic dev provider (`email/dev-provider.js`) — every notification
still goes through the full queue/worker/retry pipeline, but nothing ever reaches a real
network. `platform/test/e2e-phase5c.js` and the Phase 5C drills run entirely this way.
**Live delivery — opt-in, one message, once credentials exist:**
```bash
RUN_GWS_EMAIL_TEST=true \
TEST_EMAIL_RECIPIENT=[email protected] \
GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \
EMAIL_FROM_ADDRESS=[email protected] \
GOOGLE_DELEGATED_USER=[email protected] \
EMAIL_FROM_NAME="RootNode Academy" \
node infra/scripts/gws-email-test.js
```
(Omit `GOOGLE_DELEGATED_USER` entirely if `EMAIL_FROM_ADDRESS` is itself a primary account
rather than an alias — see §5.) Sends exactly one clearly-marked test message to
`TEST_EMAIL_RECIPIENT` — never inferred from production/student data, never run automatically as
part of any regression suite. Without `RUN_GWS_EMAIL_TEST=true` it does nothing and exits
cleanly.
## 8. Production secret handling
- `GOOGLE_SERVICE_ACCOUNT_JSON` is exactly as sensitive as a password — store it in whatever
secret manager/environment-injection mechanism the production deployment uses (see
`docs/PRODUCTION_DEPLOYMENT.md` for the general pattern this repo follows for other secrets
like `PROVISIONER_SHARED_SECRET`). **Never commit it, never put it in a Docker image layer,
never log it.** `gws-provider.js` reads it fresh from the environment on every send and never
writes it to disk.
- Nothing in this codebase ever returns `GOOGLE_SERVICE_ACCOUNT_JSON`'s contents, an access
token, or any part of it through an API response, log line, or audit event.
## 9. Credential rotation
1. Google Cloud Console → the service account → Keys → Add Key → Create new key (JSON) — this
creates an ADDITIONAL valid key without invalidating the old one yet.
2. Update `GOOGLE_SERVICE_ACCOUNT_JSON` in production's secret store to the new key, redeploy/
restart the Platform API.
3. Confirm delivery still works (§7's live test, or just watch `/api/admin/notifications` for a
real send after the restart).
4. Google Cloud Console → the service account → Keys → delete the OLD key.
Domain-wide delegation authorization (§4) is tied to the service account's Client ID, not to any
individual key — rotating keys does not require re-authorizing delegation.
## 10. Revocation (compromised credential / offboarding)
1. Google Cloud Console → the service account → Keys → delete the affected key immediately —
this invalidates it instantly, before anything else.
2. If the whole service account should stop being able to send at all: Google Admin Console →
Security → API controls → Domain-wide Delegation → remove that Client ID's authorization.
3. Rotate `EMAIL_FROM_ADDRESS`'s own Workspace login password as a precaution even though the
application never used it — a compromised service-account key doesn't imply the mailbox's own
credentials were exposed, but treat it as a "review this account" trigger.
## 11. Troubleshooting
| 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_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. |
## 12. Google Workspace quotas/limits relevant to this mechanism
Gmail API sending inherits the same per-user daily sending limits as regular Gmail/Workspace
sending (limits vary by Workspace edition — check current figures in Google's own Workspace
admin documentation, as these change over time and are not something to hard-code here). For
RootNode Academy's transactional volume (activation/enrollment/reset emails, not bulk/marketing
— explicitly out of scope, see the Phase 5C authorization §5/§28), this should sit comfortably
under any Workspace edition's per-user limit; revisit if enrollment volume grows substantially.