From 7baf7074e504e7a829e95d6917164da45abc8160 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 17:38:59 -0500 Subject: [PATCH] fix(email): support a delegated-mailbox alias distinct from the delegated Workspace account Real-world case found during setup: the sending address (academy@rootnodeacademy.com) 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. --- docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md | 34 ++++++++++++++++++++------ platform/api/src/email/gws-provider.js | 16 +++++++++--- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md b/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md index 8c3cb34..2dc878f 100644 --- a/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md +++ b/docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md @@ -29,6 +29,16 @@ you set `EMAIL_FROM_ADDRESS` to (§5). 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 `academy@rootnodeacademy.com` is an alias of, +say, `chase@rootnodeacademy.com`, 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 @@ -54,8 +64,10 @@ you set `EMAIL_FROM_ADDRESS` to (§5). granting them would violate least-privilege for no benefit. 4. Authorize. -This is the step that lets the service account act as `EMAIL_FROM_ADDRESS` specifically — it -does not grant access to any other mailbox in the domain. +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 @@ -66,11 +78,16 @@ dev: `platform/.env`, already gitignored): |---|---|---| | `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 mailbox from §2, e.g. `noreply@rootnodeacademy.com` | Also the delegated-user (`sub`) claim. | +| `EMAIL_FROM_ADDRESS` | the address students see mail arrive from, e.g. `academy@rootnodeacademy.com` | 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. `chase@rootnodeacademy.com` | 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. `support@rootnodeacademy.com` | 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_FROM_ADDRESS=academy@rootnodeacademy.com` +(what students see), `GOOGLE_DELEGATED_USER=chase@rootnodeacademy.com` (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 @@ -104,13 +121,16 @@ network. `platform/test/e2e-phase5c.js` and the Phase 5C drills run entirely thi RUN_GWS_EMAIL_TEST=true \ TEST_EMAIL_RECIPIENT=you@example.com \ GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \ -EMAIL_FROM_ADDRESS=noreply@rootnodeacademy.com \ +EMAIL_FROM_ADDRESS=academy@rootnodeacademy.com \ +GOOGLE_DELEGATED_USER=chase@rootnodeacademy.com \ EMAIL_FROM_NAME="RootNode Academy" \ node infra/scripts/gws-email-test.js ``` -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. +(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 diff --git a/platform/api/src/email/gws-provider.js b/platform/api/src/email/gws-provider.js index 5f49ac9..535d80d 100644 --- a/platform/api/src/email/gws-provider.js +++ b/platform/api/src/email/gws-provider.js @@ -1,6 +1,16 @@ // Phase 5C §2 — the Google Workspace adapter. Sends via the Gmail API // (users.messages.send), authenticated as a Google Cloud service account using Workspace -// domain-wide delegation to impersonate a dedicated sending mailbox (EMAIL_FROM_ADDRESS). +// domain-wide delegation to impersonate a Workspace user. +// +// GOOGLE_DELEGATED_USER vs. EMAIL_FROM_ADDRESS — two different things, deliberately kept +// separate: domain-wide delegation authorizes the service account to act as one real Workspace +// USER ACCOUNT (the "sub" claim below) — if the sending address is an ALIAS of a real account +// rather than its own paid seat (a deliberate cost-saving choice, not a mistake), the delegated +// user must be the ALIAS'S OWNER (the real account it belongs to), not the alias string itself. +// EMAIL_FROM_ADDRESS is only ever the visible From: header — Gmail lets any account send as its +// own verified aliases, so this can safely be the alias while GOOGLE_DELEGATED_USER is the real +// account underneath it. If the sending address IS a primary account already, +// GOOGLE_DELEGATED_USER can be left unset and this falls back to EMAIL_FROM_ADDRESS. // // WHY this mechanism, not SMTP or a human login: Google's own guidance for a backend // application sending mail as a specific Workspace address is a service account + domain-wide @@ -42,8 +52,8 @@ function loadServiceAccount() { // implements. async function getAccessToken() { const sa = loadServiceAccount(); - const impersonate = process.env.EMAIL_FROM_ADDRESS; - if (!impersonate) throw configError('EMAIL_FROM_ADDRESS is not set (required as the delegated-user "sub" claim)'); + const impersonate = process.env.GOOGLE_DELEGATED_USER || process.env.EMAIL_FROM_ADDRESS; + if (!impersonate) throw configError('Set GOOGLE_DELEGATED_USER (or EMAIL_FROM_ADDRESS if the sending address is itself a primary account) — required as the delegated-user "sub" claim'); const now = Math.floor(Date.now() / 1000); const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');