n8n Google API Migration Strategy, 2026-05-11¶
Strategy + plan. No code edits in this session. Companion to google-oauth-permanent-fix-2026-05-04.
Bottom line¶
This is a live user-facing regression, not janitorial cleanup. The dead n8n list-recent-emails workflow has been silently degrading forge_morning_report.py since the 2026-04-30 direct-client cutover, roughly 11 days. Every morning briefing in that window has shipped with a broken email section. Building forge_gmail.py and swapping the two read callers is a daily-quality bugfix, not infrastructure hygiene. Treat as P0.
Two Google surfaces still ride n8n: Gmail (11 webhooks) and Calendar reads (1 webhook in 3 callers). Calendar already has a direct client (forge_google_calendar.py), the callers just never switched. Gmail has no direct client; it must be built. Recommended order: P0 Gmail-read migration to stop the regression today, then Calendar caller swap (zero new code), then full Gmail surface, then layer the permanent-auth migration from the 05-04 handoff underneath without rewriting callers.
A. Audit of remaining n8n Google calls¶
Grep across forge/scripts/ for n8n("...") patterns. Notion-* webhooks are out of scope (not Google) and stay on n8n until a separate decision.
Gmail (needs migration)¶
All in forge_telegram_inbox_brain.py plus forge_morning_report.py. All currently broken or about to be (live blocker: list-recent-emails).
| Webhook | Callers | Surface | Classify |
|---|---|---|---|
list-recent-emails |
forge_telegram_inbox_brain.py:1091,1118, forge_morning_report.py:232,237 |
Read | Needs migration, BLOCKER |
get-email-body |
inbox_brain.py:1105 |
Read | Needs migration |
get-email-thread |
inbox_brain.py:1140 |
Read | Needs migration |
archive-emails |
inbox_brain.py:1155 |
State change | Needs migration + confirm |
clear-emails |
inbox_brain.py:1162 |
State change | Needs migration + confirm |
star-email |
inbox_brain.py:1169 |
State change | Needs migration + confirm |
mark-as-read |
inbox_brain.py:1176 |
State change | Needs migration + confirm |
apply-label |
inbox_brain.py:1183 |
State change | Needs migration + confirm |
unsubscribe-sender |
inbox_brain.py:1190 |
Sends mailto | Needs migration, personal Gmail per-call approval per CLAUDE.md |
block-sender |
inbox_brain.py:1197 |
Filter create | Needs migration + confirm |
manage-gmail-drafts |
inbox_brain.py:1208 |
Draft CRUD | Needs migration + confirm |
| Dynamic workflow call | inbox_brain.py:1225 |
Variable | Audit before migration; likely already covered above |
Calendar (needs migration, but direct client already exists)¶
| Webhook | Callers | Classify |
|---|---|---|
list-calendar-events |
forge_heartbeat_random.py:202, forge_morning_report.py:163, forge_evening_winddown.py:43,73 |
Migration is a 1-import swap to forge_google_calendar.list_events |
Already migrated (Calendar writes)¶
Create/update/delete Calendar events already run through forge_google_calendar.py per the 2026-04-30 cutover. No n8n calendar-write webhooks called from forge scripts.
Staying on n8n (intentional)¶
- All
notion-*webhooks. Not Google; separate concern. Stays until Notion direct client gets built (not gated by this work). forge_notify.shn8n channel. Generic notify relay, not a Google API surface (n8n on the other end may send email but the call from Forge is to n8n, not Google).
B. Current OAuth landscape¶
One shared OAuth client 954659188872-...apps.googleusercontent.com, Testing mode, 7-day refresh-token expiry. Confirms the "one canonical Forge OAuth client" claim in reference_google_oauth_architecture.md.
| Surface | Token store | OAuth client | Posture |
|---|---|---|---|
forge_google_calendar.py |
~/.forge-secrets/google-calendar.env |
954659188872-... |
Sensitive scope, Testing, 7-day refresh expiry |
n8n [email protected] Gmail cred |
n8n encrypted DB | 954659188872-... |
Restricted scope (gmail.modify), Testing, 7-day |
n8n [email protected] cred |
n8n encrypted DB | 954659188872-... |
Restricted, Testing, 7-day |
n8n Google Calendar account - [email protected] cred |
n8n encrypted DB | 954659188872-... |
Sensitive, Testing, 7-day |
| rclone Drive | rclone config | rclone's verified client 202264815644-... |
Permanent, no expiry |
Claude MCP Gmail (mcp__claude_ai_Gmail__*) |
Anthropic-side | Anthropic OAuth | Permanent, no expiry |
Single point of failure: all four Forge-authored grants live on one Testing-mode client. Pushing it to Production hard-blocks consent (Restricted scope verification needs CASA, infeasible).
C. Target architecture¶
Shared module: forge_gmail.py¶
Mirror the forge_google_calendar.py pattern: single module with read + state-changing helpers, dual-account aware (account="business"|"personal"), per-account auth backend pluggable so the permanent-fix migration can swap implementations without touching callers.
forge_gmail.list_recent(account, query, limit) # read, safe
forge_gmail.get_body(account, message_id) # read, safe
forge_gmail.get_thread(account, message_id) # read, safe
forge_gmail.get_headers(account, message_id) # read, safe (for List-Unsubscribe)
forge_gmail.archive(account, message_ids, *, confirmed=False)
forge_gmail.apply_label(account, message_id, label, *, confirmed=False)
forge_gmail.star(account, message_id, *, confirmed=False)
forge_gmail.mark_read(account, message_ids, *, confirmed=False)
forge_gmail.create_filter(account, criteria, action, *, confirmed=False)
forge_gmail.drafts.{list,create,update,delete}(account, ..., confirmed=False)
forge_gmail.send_mailto_unsub(message_id, *, confirmed=False) # personal-only edge per CLAUDE.md
forge_gmail.send(account, to, subject, body, *, confirmed=False) # business-only; personal Gmail send is BANNED
State-changing calls require confirmed=True at the module boundary. Callers (inbox brain, morning report) must surface a confirm prompt and pass it through. Module refuses any send on account="personal" regardless of confirmed (CLAUDE.md hard rule).
Auth backend, two-phase¶
Phase 1 (this migration): backend is OAuth refresh-token against the current shared client. Same posture as today: works for 7 days at a time. Acceptable bridge.
Phase 2 (rides on top of the 05-04 permanent-fix handoff, after Steps 2 and 3 complete):
- Business backend: service account + DWD, impersonate [email protected]. Permanent.
- Personal backend: Apps Script Web App (Path B from 05-04) OR forward-and-alias (Path A). Module exposes the same surface, swaps implementation.
The boundary stays inside forge_gmail.py; callers don't change.
Boundary discipline¶
| Op type | Module guard | Caller responsibility |
|---|---|---|
| Read on either account | None | Just call |
| State change on business Gmail | confirmed=True arg |
Chat-side confirm per CLAUDE.md ("business Gmail: send allowed but always confirm in chat first" generalizes to any state change) |
| State change on personal Gmail | confirmed=True arg |
Per-call (not per-batch) approval, exception for List-Unsubscribe mailto in batched flows where per-batch is the 05-04 standard |
| Send from personal Gmail | Hard refused inside module | n/a, blocked at boundary |
Inbox brain + morning report sharing¶
Yes, both import forge_gmail.py. Inbox brain becomes a UI/intent layer that calls the module; morning report's "recent emails" section calls the same list_recent. No duplicated auth logic, no duplicated retry, no duplicated rate-limiting.
D. Risk table¶
| Action | Breaks | Severity | Recovery |
|---|---|---|---|
| Status quo (do nothing) | Morning report 06:00 email section ALREADY broken since 2026-04-30, ~11 days of degraded briefings shipped | P0, ongoing daily regression | Migration IS the fix; rollback is not an option, the workflow is already dead |
Delete n8n list-recent-emails workflow with no migration |
Inbox brain list_emails / search_emails bot tools error out (morning report is already broken, no incremental damage there) |
High, user-visible in coordinator chat | Restore workflow from n8n version history if it ever worked; 5 min |
Delete n8n list-recent-emails after callers swapped to forge_gmail.list_recent |
Nothing | None | n/a |
Build forge_gmail.py on top of current OAuth without permanent-fix Steps 2-3 |
Works 7 days at a time, same posture as today | Medium, foreseeable | Re-auth via existing forge_google_calendar_reauth.py pattern, replicated for Gmail |
Swap calendar callers to forge_google_calendar.list_events |
Nothing if forge_google_calendar already supports list (verify in code) | Low | Revert import |
| Migrate state-changing personal-Gmail webhooks before confirm-prompt wired in callers | Bypass of CLAUDE.md security rule | Critical, security | Don't migrate state-change ops until callers route through confirm |
Smallest safe step that unblocks deleting the n8n surface¶
Three commits, in order:
forge_gmail.pyv0 with justlist_recent(account, query, limit)andget_body(account, message_id), OAuth refresh-token backend (copy auth scaffold fromforge_google_calendar.py).- Swap callers:
forge_telegram_inbox_brain.py:1091,1105,1118andforge_morning_report.py:232,237. - Mark n8n
list-recent-emailsandget-email-bodyworkflows Active=OFF; leave in n8n one week as rollback; then delete.
That's it for unblocking. The other 9 Gmail webhooks aren't dead today, they're just on borrowed time with the 7-day OAuth cycle.
1-day path vs 1-week path¶
1-day path (unblock only):
- Build forge_gmail.py v0: list_recent + get_body only.
- Swap morning report + inbox brain list_emails/search_emails/get_email_body tools.
- Turn off the two n8n workflows.
- Result: blocker cleared, 9 Gmail webhooks still on n8n, 7-day OAuth cycle still ticking on everything.
1-week path (full migration to direct + permanent auth):
- Day 1: 1-day path above.
- Day 2: extend forge_gmail.py to full surface (archive, label, star, mark-read, filter, drafts, mailto-unsub, get-thread, get-headers). Wire confirm-prompt path through inbox brain.
- Day 3: swap remaining 9 inbox-brain Gmail callers.
- Day 4: turn off remaining n8n Gmail workflows; delete after one-week soak.
- Day 5: swap 3 calendar callers (forge_heartbeat_random.py, forge_morning_report.py, forge_evening_winddown.py) to forge_google_calendar.list_events. Turn off list-calendar-events workflow.
- Day 6: execute 05-04 handoff Step 2 (service account + DWD for business Gmail). Swap forge_gmail.py business backend to SA+DWD.
- Day 7: execute 05-04 handoff Step 3 Path A or B (personal Gmail). Swap forge_gmail.py personal backend.
- Result: every Forge Google call direct, both Gmail accounts on permanent auth, calendar direct + permanent, n8n holds only Notion calls.
Recommended order¶
- P0, today, stop the regression. 1-day path above. Eleven consecutive morning reports have shipped broken; the next one runs at 06:00 tomorrow. Land before then. This is the highest-priority item on the implementing worker's plate, ahead of any other Forge work in flight.
- This week, calendar callers. Trivial; no new auth surface. Cleans up the 3 callers + 1 workflow.
- This week, full Gmail surface. Build out the rest of
forge_gmail.py, swap remaining inbox-brain callers, run on current OAuth. - Next week, permanent auth. Execute 05-04 handoff Steps 2 (business DWD) and 3 (personal Apps Script or forward-alias).
forge_gmail.pybackends swap; callers unchanged. - Defer. Notion direct client. YouTube. Brand email consolidation. None are gated by this work.
Open decisions for the implementing worker¶
- Confirm
forge_google_calendar.list_eventsexists and matches the n8nlist-calendar-eventsresponse shape; if not, add it before step 2. - Decide test-user mode for
forge_gmail.pyPhase 1: reuse the calendar OAuth refresh-token flow exactly, including persistence helper (forge_google_calendar.py:74-115per the 05-04 handoff). - Confirm CLAUDE.md "business Gmail: send allowed but always confirm in chat first" generalizes to ALL business state changes (archive/label/draft), or whether reads-and-archive are bulk-approvable. Default to per-batch approval matching the personal Gmail policy until Justin says otherwise.
- Decide whether the dynamic-workflow call at
forge_telegram_inbox_brain.py:1225is a Gmail surface or covered by one of the existing rows; audit during step 3.
Verification checklist for the implementing worker¶
- Morning report 06:00 run produces a populated email section.
- Inbox brain
list_emails,search_emails,get_email_bodytools return results in coordinator chat. -
forge_gmail.archiverefuses to run withoutconfirmed=True. -
forge_gmail.send(account="personal", ...)raises at module boundary, not at API call. - After all callers swapped,
grep -rn 'n8n("\(list-recent-emails\|get-email-body\|archive-emails\|clear-emails\|star-email\|mark-as-read\|apply-label\|unsubscribe-sender\|block-sender\|manage-gmail-drafts\|get-email-thread\|list-calendar-events\)"' scripts/returns nothing. - All 11 n8n Gmail workflows + 1 calendar workflow Active=OFF; soak one week; delete.
[Claude Code, n8n-google-migration_Opus47]