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.
12 KiB
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).
- 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).
- 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)
- In Google Cloud Console (console.cloud.google.com), create or choose a GCP project associated with your Workspace organization.
- Enable the Gmail API for that project (APIs & Services → Library → search "Gmail API" → Enable).
- 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. - On that service account, create a JSON key (Keys → Add Key → Create new key → JSON). This
downloads a file containing
client_emailandprivate_key— this is the sensitive credential, treat it exactly like a password (see §8). - 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
- Google Admin Console → Security → Access and data control → API controls → Domain-wide Delegation → Add new.
- Client ID: the numeric ID from step 3.5 above.
- 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. - 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
_dmarcTXT record (start withp=noneto 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:
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_JSONis exactly as sensitive as a password — store it in whatever secret manager/environment-injection mechanism the production deployment uses (seedocs/PRODUCTION_DEPLOYMENT.mdfor the general pattern this repo follows for other secrets likePROVISIONER_SHARED_SECRET). Never commit it, never put it in a Docker image layer, never log it.gws-provider.jsreads 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
- 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.
- Update
GOOGLE_SERVICE_ACCOUNT_JSONin production's secret store to the new key, redeploy/ restart the Platform API. - Confirm delivery still works (§7's live test, or just watch
/api/admin/notificationsfor a real send after the restart). - 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)
- Google Cloud Console → the service account → Keys → delete the affected key immediately — this invalidates it instantly, before anything else.
- 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.
- 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.