Skip to content

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.sh n8n 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:

  1. forge_gmail.py v0 with just list_recent(account, query, limit) and get_body(account, message_id), OAuth refresh-token backend (copy auth scaffold from forge_google_calendar.py).
  2. Swap callers: forge_telegram_inbox_brain.py:1091,1105,1118 and forge_morning_report.py:232,237.
  3. Mark n8n list-recent-emails and get-email-body workflows 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.

  1. 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.
  2. This week, calendar callers. Trivial; no new auth surface. Cleans up the 3 callers + 1 workflow.
  3. This week, full Gmail surface. Build out the rest of forge_gmail.py, swap remaining inbox-brain callers, run on current OAuth.
  4. Next week, permanent auth. Execute 05-04 handoff Steps 2 (business DWD) and 3 (personal Apps Script or forward-alias). forge_gmail.py backends swap; callers unchanged.
  5. Defer. Notion direct client. YouTube. Brand email consolidation. None are gated by this work.

Open decisions for the implementing worker

  1. Confirm forge_google_calendar.list_events exists and matches the n8n list-calendar-events response shape; if not, add it before step 2.
  2. Decide test-user mode for forge_gmail.py Phase 1: reuse the calendar OAuth refresh-token flow exactly, including persistence helper (forge_google_calendar.py:74-115 per the 05-04 handoff).
  3. 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.
  4. Decide whether the dynamic-workflow call at forge_telegram_inbox_brain.py:1225 is 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_body tools return results in coordinator chat.
  • forge_gmail.archive refuses to run without confirmed=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]