Skip to content

Multi-account Google support for mail and calendar#28

Merged
steve8708 merged 10 commits intomainfrom
updates-4
Mar 16, 2026
Merged

Multi-account Google support for mail and calendar#28
steve8708 merged 10 commits intomainfrom
updates-4

Conversation

@steve8708
Copy link
Contributor

Summary

  • Multi-account Google auth: Connect multiple Google accounts (work, personal, etc.) to both mail and calendar templates
  • Per-account token storage: Tokens stored in data/google-accounts/{email}.json with auto-migration from legacy single-account file
  • Aggregated data fetching: Emails and calendar events fetched from all connected accounts in parallel
  • Account management UI: Connect banner transforms into an account strip showing connected emails with add/remove capabilities
  • Account attribution: Each email/event includes accountEmail field identifying which account it came from

Test plan

  • Connect a Google account in mail template, verify real emails load
  • Add a second Google account, verify emails from both appear
  • Disconnect one account via the "x" button, verify it's removed
  • Connect Google account in calendar, verify events load from all accounts
  • Verify legacy data/google-auth.json auto-migrates to new format
  • Verify demo data shows when no accounts are connected

🤖 Generated with Claude Code

- Store tokens per-account in data/google-accounts/{email}.json
- Aggregate emails/events across all connected accounts in parallel
- Add accountEmail field to EmailMessage and CalendarEvent types
- Connect banner shows account strip when connected with add/remove
- Auto-migrate from legacy single-account data/google-auth.json
- exchangeCode fetches user email via Gmail/OAuth2 API after auth
@netlify
Copy link

netlify bot commented Mar 16, 2026

Deploy Preview for agent-native-fw ready!

Name Link
🔨 Latest commit 8badfa2
🔍 Latest deploy log https://app.netlify.com/projects/agent-native-fw/deploys/69b827b0152cda0008140bb8
😎 Deploy Preview https://deploy-preview-28--agent-native-fw.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@teleaziz teleaziz marked this pull request as draft March 16, 2026 04:06
@teleaziz teleaziz marked this pull request as ready for review March 16, 2026 04:06
@teleaziz teleaziz marked this pull request as draft March 16, 2026 04:19
@teleaziz teleaziz marked this pull request as ready for review March 16, 2026 04:19
@teleaziz teleaziz marked this pull request as draft March 16, 2026 04:33
@teleaziz teleaziz marked this pull request as ready for review March 16, 2026 04:33
Copy link

@builder-io-integration builder-io-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder has reviewed your changes and found 6 potential issues.

Review Details

Code Review Summary — PR #28: Multi-account Google OAuth

This PR migrates both mail and calendar templates from single-account to multi-account Google OAuth. Tokens are stored per-account as data/google-accounts/{email}.json with auto-migration from a legacy single file. Emails and calendar events are fetched from all connected accounts in parallel, and a new account management UI lets users add/remove accounts.

Risk Level: 🔴 HIGH — This PR touches authentication, token storage, and introduces new security-sensitive paths derived from user input.

The overall architecture is reasonable: using files as the account store fits the agent-native framework, and the parallel fetch pattern is correct. However, several security and correctness bugs were found by all 5 parallel review agents.

🔴 HIGH — Path Traversal in disconnect() (mail + calendar)

The user-supplied email from req.body is passed directly to path.join(ACCOUNTS_DIR, \${email}.json`)and then deleted — no bounds check. Sending{"email": "../../settings"}deletesdata/settings.json. Any .json` file reachable by the process is at risk. Found by 5/5 agents.

🔴 HIGH — XSS in OAuth Callback HTML (mail + calendar)

The email value from exchangeCode() is interpolated unescaped into an inline <script> block as a JS string literal that is also assigned to innerHTML. RFC 5321 permits ' in quoted local-parts; a quote terminates the string and executes arbitrary JS in the OAuth popup origin. Found by 5/5 agents.

🟡 MEDIUM — Race condition in migrateLegacyTokens(), stale token closure, and error masking in multi-account operations (see comments).

Reviewed using 5 parallel code-review agents with randomized file ordering to counter position bias.


View in Builder.io

Code review by Builder.io

- Path traversal: disconnect() now validates email against known account
  files instead of joining raw input into path
- XSS: callback page uses JSON.stringify + textContent instead of
  interpolating email into innerHTML
- Race condition: migrateLegacyTokens() uses module-level promise to
  deduplicate concurrent calls
- Stale closure: token refresh handler re-reads file from disk instead
  of merging onto captured snapshot
Copy link

@builder-io-integration builder-io-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder has reviewed your changes and found 5 potential issues.

Review Details

Code Review Summary — PR #28 Update: Multi-account Google OAuth

The previous 6 issues (path traversal ×2, XSS ×2, migration race, stale token closure) are all cleanly fixed in this update — good work. The getAccountFiles() lookup pattern for disconnect, JSON.stringify + textContent for the callback page, module-level migrationPromise, and re-reading the file inside the refresh handler are all correct approaches.

Risk Level: 🔴 HIGH — still touches auth and credential storage.

Three new issues were found by 2-4 of 4 parallel agents.

🔴 HIGH — Calendar OAuth completely broken (missing email scope)

The calendar template's SCOPES only includes calendar.readonly and calendar.events. The new exchangeCode() now calls oauth2.userinfo.get() to resolve the email for the filename, but that endpoint requires openid or userinfo.email scope. Connecting a Google Calendar account will throw a 403 from the userinfo call and return HTTP 500 to the user — no account is stored. The mail template uses gmail.users.getProfile() which works with its existing Gmail scope, so mail is unaffected.

🔴 HIGH — Silent token overwrite via "unknown" fallback (mail + calendar)

Both exchangeCode() implementations fall back to "unknown" when Google returns no email, writing unknown.json. A second OAuth flow that also fails email resolution silently overwrites the first account's tokens — permanent credential loss with no error surfaced.

🟡 MEDIUM — getEmail/markRead swallow auth errors and markRead targets wrong account

All errors (401, 403, 429) are caught as "try next account". markRead doesn't even destructure email from the client loop, making it unable to route by accountEmail even if the client sends it — instead firing N Gmail API calls on every read-toggle.

Reviewed with 4 parallel code-review agents on the full PR diff.


View in Builder.io

Code review by Builder.io

- Add userinfo.email scope to calendar OAuth
- Throw on missing email instead of silent "unknown" fallback
- getEmail: only continue to next account on 404, surface other errors
- markRead: route via accountEmail instead of iterating all clients
- Delete corrupt legacy token file to prevent infinite retry loop
Copy link

@builder-io-integration builder-io-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder has reviewed your changes and found 6 potential issues.

Review Details

Code Review Summary (Round 3)

PR #28 adds multi-account Google OAuth to both mail and calendar templates, with per-account token files in data/google-accounts/{email}.json, legacy auto-migration, and parallel aggregated fetching. Risk: HIGH (OAuth flows, token storage, credential routing). All 5 issues from round 2 were correctly fixed. Reviewed using 4 parallel agents with randomized file ordering.

Key Findings

🔴 HIGH:

  • getClient removed from import but still called in markRead — compile error + runtime ReferenceError, breaking all read/unread toggling for Gmail accounts

🟡 MEDIUM:

  • listGmailMessages() and listEvents() swallow all per-account errors (401, 429, network failures), silently returning [] and turning auth failures into an empty inbox/calendar
  • updateEvent in google-calendar.ts now routes by event.accountEmail, but routes/events.ts still passes req.body (which never includes accountEmail), so every calendar event update routes to the first connected account
  • Migration fallback filenames ("migrated-account", "unknown-account") become permanent fake email addresses since the filename is the source of truth for account identity throughout the codebase
  • error?.code (a Node.js string like 'ECONNRESET') passed to res.status(), triggering ERR_HTTP_INVALID_STATUS_CODE and dropping the connection on network errors

Found using 4 parallel code-review agents with randomized file ordering.


View in Builder.io

Code review by Builder.io

try {
const client = await getClient();
// Route to specific account if provided, otherwise try first client
const client = await getClient(accountEmail);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClient called but not imported — ReferenceError crashes markRead

🔴 High Priority

The PR replaced getClient with getClients in the import but markRead still calls getClient(accountEmail) on this line, which is not in scope — confirmed by pnpm typecheck failing with TS2552: Cannot find name 'getClient'. Every PATCH /api/emails/:id/read request while Google is connected throws a ReferenceError, making read/unread toggling completely broken. Fix: add getClient back to the import alongside getClients.


How did I do? React with 👍 or 👎 to help me improve.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added getClient back to the import.

`[listGmailMessages] Error fetching from ${email}:`,
error.message,
);
return [];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listGmailMessages swallows 401/429 errors — auth failures silently appear as empty inbox

🟡 Medium Priority

The catch block returns [] for all errors including 401 (expired/revoked token) and 429 (rate limit), so a single expired account causes the entire inbox to return a successful 200 [] response with no indication of failure. Fix: only suppress 404-style not-found conditions; rethrow 401/429/5xx so the route can return an appropriate error status.


How did I do? React with 👍 or 👎 to help me improve.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially agreed — with multiple accounts, one expired token shouldn't blank the whole inbox. Changed listGmailMessages to return { messages, errors }. The route now returns 502 when ALL accounts fail, but returns results with an X-Account-Errors header when only some fail. This way partial data still shows while surfacing the issue.

`[listEvents] Error fetching from ${email}:`,
error.message,
);
return [];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listEvents swallows 401/429 errors — auth failures silently appear as empty calendar

🟡 Medium Priority

Same pattern as listGmailMessages: the per-account catch in listEvents returns [] for all errors, turning a 401/429 on the only connected account into an empty calendar with a 200 status rather than an actionable error. Fix: propagate non-recoverable Google API errors (auth failures, rate limits) to the route instead of suppressing them.


How did I do? React with 👍 or 👎 to help me improve.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — same approach as mail. Returns 502 when all accounts fail, partial results + X-Account-Errors header otherwise.

event: Partial<CalendarEvent>,
): Promise<void> {
const client = await getClient();
const client = await getClient(event.accountEmail);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateEvent routes by event.accountEmail but callers pass req.body — updates wrong account

🟡 Medium Priority

updateEvent now calls getClient(event.accountEmail), but routes/events.ts passes raw req.body to this function — and req.body never includes accountEmail (confirmed by EventDialog.tsx which sends only { id, title, description, location, start, end } and drag-drop handlers which send only { id, start, end }). With two accounts, getClient(undefined) falls back to files[0], so every calendar event update writes to the first connected account regardless of which account owns the event. Fix: pass the merged updated object (which has accountEmail from the stored event) instead of req.body.


How did I do? React with 👍 or 👎 to help me improve.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now passes the merged updated object (which inherits accountEmail from the stored event) instead of raw req.body.

}
} catch {
// If we can't reach Gmail (offline, expired), just move with a fallback name
writeJsonFile(path.join(ACCOUNTS_DIR, "migrated-account.json"), tokens);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration fallback filenames become permanent fake account emails in the UI

🟡 Medium Priority

When migration runs while offline or Google returns no email, tokens are written as migrated-account.json or unknown-account.json; since path.basename(file, '.json') is the sole source of truth for account email throughout the codebase, these synthetic names surface permanently in the UI account strip and can cause phantom duplicate entries when the user later reconnects the same address. The same issue exists in templates/calendar/server/lib/google-calendar.ts line 78. Fix: on migration failure, delete the legacy file without writing a fallback rather than persisting an unattributable token.


How did I do? React with 👍 or 👎 to help me improve.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — migration no longer writes fallback files. If email can't be resolved (offline/no email returned), the legacy file is deleted without persisting an unattributable token.

if (status === 404) continue;
// Surface auth/rate-limit/other errors immediately
console.error("[getEmail] Gmail error:", error.message);
res.status(status || 500).json({ error: error.message });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error?.code (Node.js string) passed to res.status() — crashes response on network errors

🟡 Medium Priority

error?.code for network-level failures (ECONNRESET, ETIMEDOUT, ENOTFOUND, etc.) is a truthy Node.js string constant, so status || 500 evaluates to the string rather than 500; res.status('ECONNRESET') then throws ERR_HTTP_INVALID_STATUS_CODE and drops the connection instead of returning a clean error response. Fix: guard with typeof error?.response?.status === 'number' before using it, and fall back to 502 for network-level errors.


How did I do? React with 👍 or 👎 to help me improve.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now checks typeof error?.response?.status === 'number' and falls back to 502 for network-level errors.

- Add getClient to emails.ts import (was missing, crashed markRead)
- Fix error?.code string passed to res.status() — use numeric check + 502 fallback
- Migration: don't write phantom fallback files, just delete legacy on failure
- updateEvent: pass merged object (has accountEmail) instead of raw req.body
- listGmailMessages/listEvents: return errors alongside results, surface
  as 502 when all accounts fail, X-Account-Errors header for partial failures
@steve8708 steve8708 enabled auto-merge March 16, 2026 15:49
@steve8708 steve8708 merged commit 4c3aacb into main Mar 16, 2026
9 checks passed
@steve8708 steve8708 deleted the updates-4 branch March 16, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant