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.
This commit is contained in:
2026-08-25 17:38:59 -05:00
parent 777efd168d
commit 7baf7074e5
2 changed files with 40 additions and 10 deletions
+27 -7
View File
@@ -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 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. 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) ## 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 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. granting them would violate least-privilege for no benefit.
4. Authorize. 4. Authorize.
This is the step that lets the service account act as `EMAIL_FROM_ADDRESS` specifically — it This grants the service account permission to act as **one specific Workspace user account**.
does not grant access to any other mailbox in the domain. 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 ## 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. | | `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. | | `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. `norepl[email protected]` | Also the delegated-user (`sub`) claim. | | `EMAIL_FROM_ADDRESS` | the address students see mail arrive from, e.g. `academ[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_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. | | `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. | | `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) ## 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 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 \ RUN_GWS_EMAIL_TEST=true \
TEST_EMAIL_RECIPIENT=[email protected] \ TEST_EMAIL_RECIPIENT=[email protected] \
GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \ GOOGLE_SERVICE_ACCOUNT_JSON="$(cat /path/to/service-account-key.json)" \
EMAIL_FROM_ADDRESS=norepl[email protected] \ EMAIL_FROM_ADDRESS=academ[email protected] \
GOOGLE_DELEGATED_USER=[email protected] \
EMAIL_FROM_NAME="RootNode Academy" \ EMAIL_FROM_NAME="RootNode Academy" \
node infra/scripts/gws-email-test.js node infra/scripts/gws-email-test.js
``` ```
Sends exactly one clearly-marked test message to `TEST_EMAIL_RECIPIENT` — never inferred from (Omit `GOOGLE_DELEGATED_USER` entirely if `EMAIL_FROM_ADDRESS` is itself a primary account
production/student data, never run automatically as part of any regression suite. Without rather than an alias — see §5.) Sends exactly one clearly-marked test message to
`RUN_GWS_EMAIL_TEST=true` it does nothing and exits cleanly. `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 ## 8. Production secret handling
+13 -3
View File
@@ -1,6 +1,16 @@
// Phase 5C §2 — the Google Workspace adapter. Sends via the Gmail API // Phase 5C §2 — the Google Workspace adapter. Sends via the Gmail API
// (users.messages.send), authenticated as a Google Cloud service account using Workspace // (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 // 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 // application sending mail as a specific Workspace address is a service account + domain-wide
@@ -42,8 +52,8 @@ function loadServiceAccount() {
// implements. // implements.
async function getAccessToken() { async function getAccessToken() {
const sa = loadServiceAccount(); const sa = loadServiceAccount();
const impersonate = process.env.EMAIL_FROM_ADDRESS; const impersonate = process.env.GOOGLE_DELEGATED_USER || process.env.EMAIL_FROM_ADDRESS;
if (!impersonate) throw configError('EMAIL_FROM_ADDRESS is not set (required as the delegated-user "sub" claim)'); 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 now = Math.floor(Date.now() / 1000);
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');