From 8979cc842d79b4fb04fb805e345c6436c38cdfab Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Fri, 27 Mar 2026 13:06:33 -0700 Subject: [PATCH 1/2] fix: session endpoint returns real identity, try/catch on DB, production email fix - /api/auth/session endpoints call getSession() instead of returning DEV_SESSION - try/catch around getSessionEmail() in dev mode for DB startup errors - Production getSession uses getSessionEmail() to return real email (was hardcoded "user") - Client and server now show same identity in all environments --- packages/core/src/client/CommandMenu.tsx | 2 +- packages/core/src/server/auth.ts | 33 +++- packages/desktop-app/src/main/index.ts | 65 ++++++- packages/desktop-app/src/renderer/App.tsx | 7 + .../src/renderer/components/AppWebview.tsx | 19 ++ .../mail/app/components/email/SnoozeModal.tsx | 179 +++++------------- .../mail/server/handlers/scheduled-jobs.ts | 15 +- 7 files changed, 163 insertions(+), 157 deletions(-) diff --git a/packages/core/src/client/CommandMenu.tsx b/packages/core/src/client/CommandMenu.tsx index 66928e19..18bd5a5d 100644 --- a/packages/core/src/client/CommandMenu.tsx +++ b/packages/core/src/client/CommandMenu.tsx @@ -312,7 +312,7 @@ export function CommandMenu({
{ - if (isDevMode()) return DEV_SESSION; - if (authDisabledMode) return DEV_SESSION; + if (isDevMode() || authDisabledMode) { + // Check for a real session cookie (created by Google OAuth callback) + // so dev and prod share the same identity on the same DB + try { + const cookie = getCookie(event, COOKIE_NAME); + if (cookie) { + const email = await getSessionEmail(cookie); + if (email) return { email, token: cookie }; + } + } catch { + // DB not ready yet — fall back to dev session + } + return DEV_SESSION; + } if (customGetSession) return customGetSession(event); const cookie = getCookie(event, COOKIE_NAME); - if (cookie && (await hasSession(cookie))) { - return { email: "user", token: cookie }; + if (cookie) { + const email = await getSessionEmail(cookie); + if (email) return { email, token: cookie }; } return null; } @@ -487,7 +502,7 @@ export function autoMountAuth(app: H3App, options: AuthOptions = {}): boolean { // Dev mode — skip auth entirely if (isDevMode()) { - // Mount a session endpoint that returns the dev stub + // Mount a session endpoint that checks for a real session first app.use( "/api/auth/session", defineEventHandler(async (event) => { @@ -495,7 +510,7 @@ export function autoMountAuth(app: H3App, options: AuthOptions = {}): boolean { setResponseStatus(event, 405); return { error: "Method not allowed" }; } - return DEV_SESSION; + return await getSession(event); }), ); @@ -586,7 +601,7 @@ export function autoMountAuth(app: H3App, options: AuthOptions = {}): boolean { "Ensure this app is behind infrastructure-level auth (Cloudflare Access, VPN, etc.).", ); - // Mount session endpoint — getSession() will return DEV_SESSION + // Mount session endpoint app.use( "/api/auth/session", defineEventHandler(async (event) => { @@ -594,7 +609,7 @@ export function autoMountAuth(app: H3App, options: AuthOptions = {}): boolean { setResponseStatus(event, 405); return { error: "Method not allowed" }; } - return DEV_SESSION; + return await getSession(event); }), ); app.use( diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 8d1fa7c4..02a3bf09 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -199,20 +199,57 @@ ipcMain.on(IPC.INTER_APP_SEND, (event: IpcMainEvent, msg: InterAppMessage) => { }); }); +// ---------- OAuth popup handling ---------- +// Open OAuth flows in an Electron BrowserWindow so the callback stays +// inside the app. Other popups still open in the system browser. + +const OAUTH_HOSTS = ["accounts.google.com"]; + +function openAuthWindow(url: string) { + const authWin = new BrowserWindow({ + width: 500, + height: 680, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + + authWin.loadURL(url); + + // After the OAuth provider redirects to localhost (the callback), + // let the request complete then close the popup. + authWin.webContents.on("did-navigate", (_event, navUrl) => { + try { + const parsed = new URL(navUrl); + if (parsed.hostname === "localhost") { + // Give the callback handler time to store tokens then close + setTimeout(() => { + if (!authWin.isDestroyed()) authWin.close(); + }, 1500); + } + } catch { + // ignore + } + }); +} + // ---------- Webview popup handling ---------- -// Open popups from webviews (e.g. OAuth flows) in the system browser -// instead of creating broken Electron popup windows. app.on("web-contents-created", (_event, contents) => { // Only intercept webview guest contents if (contents.getType() !== "webview") return; contents.setWindowOpenHandler(({ url }) => { - // Only allow http/https URLs to prevent protocol-handler attacks - // (e.g. ms-msdt:, file://, etc.) try { const parsed = new URL(url); - if (parsed.protocol === "https:" || parsed.protocol === "http:") { + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { action: "deny" }; + } + // OAuth flows stay in Electron so the callback redirect works + if (OAUTH_HOSTS.includes(parsed.hostname)) { + openAuthWindow(url); + } else { shell.openExternal(url); } } catch { @@ -245,9 +282,13 @@ app.on("web-contents-created", (_event, contents) => { return; } - // Forward other Cmd+ shortcuts: T, Shift+T, 1-9, [, ] + // Forward other Cmd+ shortcuts: R, T, Shift+T, 1-9, [, ] const isShortcut = - key === "t" || key === "[" || key === "]" || (key >= "1" && key <= "9"); + key === "r" || + key === "t" || + key === "[" || + key === "]" || + (key >= "1" && key <= "9"); if (isShortcut) { event.preventDefault(); @@ -311,6 +352,16 @@ app.whenReady().then(() => { return; } + // Cmd+R — refresh active webview, not the shell + if (key === "r") { + _event.preventDefault(); + win.webContents.send("shortcut:keydown", { + key: "r", + shiftKey: input.shift, + }); + return; + } + // Cmd+W — close tab instead of window if (key === "w") { _event.preventDefault(); diff --git a/packages/desktop-app/src/renderer/App.tsx b/packages/desktop-app/src/renderer/App.tsx index 896b3260..a5c64e5c 100644 --- a/packages/desktop-app/src/renderer/App.tsx +++ b/packages/desktop-app/src/renderer/App.tsx @@ -101,6 +101,7 @@ export default function App() { }, [enabledApps.map((a) => a.id).join(",")]); const closedTabsRef = useRef<{ tab: Tab; appId: string }[]>([]); + const [refreshKey, setRefreshKey] = useState(0); const currentAppTabs = appTabs[activeSidebarAppId]; @@ -196,6 +197,11 @@ export default function App() { (key: string, shiftKey: boolean) => { const k = key.toLowerCase(); + if (k === "r") { + setRefreshKey((n) => n + 1); + return; + } + if (k === "t") { if (shiftKey) handleReopenTab(); else handleNewTab(); @@ -314,6 +320,7 @@ export default function App() { app={appDef} appConfig={app} isActive={isActive} + refreshKey={isActive ? refreshKey : 0} /> ))}
diff --git a/packages/desktop-app/src/renderer/components/AppWebview.tsx b/packages/desktop-app/src/renderer/components/AppWebview.tsx index 77fe7518..6a0950f1 100644 --- a/packages/desktop-app/src/renderer/components/AppWebview.tsx +++ b/packages/desktop-app/src/renderer/components/AppWebview.tsx @@ -10,6 +10,8 @@ interface AppWebviewProps { /** Full app config with URL overrides (optional for backward compat) */ appConfig?: AppConfig; isActive: boolean; + /** Increment to trigger a webview reload (Cmd+R) */ + refreshKey?: number; } /** @@ -42,6 +44,7 @@ export default function AppWebview({ app, appConfig, isActive, + refreshKey = 0, }: AppWebviewProps) { const webviewRef = useRef(null); const [error, setError] = useState(false); @@ -122,6 +125,22 @@ export default function AppWebview({ }; }, [app.placeholder, isActive, app.id]); + // Cmd+R — reload the active webview when refreshKey increments + const prevRefreshKey = useRef(refreshKey); + useEffect(() => { + if (refreshKey > 0 && refreshKey !== prevRefreshKey.current) { + prevRefreshKey.current = refreshKey; + const wv = webviewRef.current; + if (wv && isActive && !app.placeholder) { + try { + wv.reloadIgnoringCache(); + } catch { + wv.reload(); + } + } + } + }, [refreshKey, isActive, app.placeholder]); + useEffect(() => { if (isActive && error && !app.placeholder) { handleRetry(); diff --git a/templates/mail/app/components/email/SnoozeModal.tsx b/templates/mail/app/components/email/SnoozeModal.tsx index 917acb38..fde0b0bf 100644 --- a/templates/mail/app/components/email/SnoozeModal.tsx +++ b/templates/mail/app/components/email/SnoozeModal.tsx @@ -205,118 +205,56 @@ export function SnoozeModal({ return (
{ if (e.target === e.currentTarget) onClose(); }} > - {/* Backdrop */} -
- - {/* Modal */} + {/* Modal — matches CommandMenu positioning and style */}
- {/* Header */} -
-
- {/* Clock icon */} - - - - - - Remind me - -
- + - if no reply - - - - -
- - {/* Input row */} -
- {/* Left accent bar */} -
- -
- setNlInput(e.target.value)} - placeholder="Try: 8 am, 3 days, aug 7" - className="flex-1 bg-transparent text-[14px] font-mono outline-none min-w-0" - style={{ - color: nlFailed - ? "rgba(255,120,100,0.8)" - : "rgba(255,255,255,0.88)", - caretColor: "#7c8bf5", - }} - /> - {nlTyping && ( - - {parseDate.isPending - ? "…" - : nlParsed - ? parsedFormatted - : "no match"} - + + + + setNlInput(e.target.value)} + placeholder="Try: 8 am, 3 days, aug 7" + className={cn( + "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground", + nlFailed && "text-destructive", )} -
+ /> + {nlTyping && ( + + {parseDate.isPending + ? "…" + : nlParsed + ? parsedFormatted + : "no match"} + + )}
{/* Options list */} -
+
+
+ Snooze until +
{options.map((opt, i) => { const active = i === selectedIndex; return ( @@ -325,43 +263,14 @@ export function SnoozeModal({ onClick={() => handleConfirm(opt)} onMouseEnter={() => setSelectedIndex(i)} className={cn( - "relative w-full flex items-center justify-between px-5 py-2.5 text-left transition-colors duration-75", + "relative w-full flex items-center justify-between rounded-sm px-2 py-1.5 text-sm text-left transition-colors", + active + ? "bg-accent text-accent-foreground" + : "hover:bg-accent hover:text-accent-foreground", )} - style={{ - background: active - ? "rgba(255,255,255,0.055)" - : "transparent", - }} > - {/* Active accent */} - {active && ( -
- )} - - - {opt.label} - - + {opt.label} + {formatRight(opt.date, opt.sublabel)} diff --git a/templates/mail/server/handlers/scheduled-jobs.ts b/templates/mail/server/handlers/scheduled-jobs.ts index 0c8bd8a3..94a21c7b 100644 --- a/templates/mail/server/handlers/scheduled-jobs.ts +++ b/templates/mail/server/handlers/scheduled-jobs.ts @@ -33,13 +33,18 @@ function ianaToOffsetMinutes(iana: string, ref: Date): number { export function parseNlDate(input: string, timezone: string): Date | null { const ref = new Date(); - const result = chrono.parseDate(input, ref, { + const opts = { timezone: ianaToOffsetMinutes(timezone, ref), forwardDate: true, - } as any); - if (!result) return null; - // Default to 8am when no time component present - if (!input.match(/\d{1,2}[:.]\d{2}|[ap]m/i)) { + } as any; + const parsed = chrono.parse(input, ref, opts); + if (!parsed.length) return null; + const result = parsed[0].start.date(); + // Default to 8am only when chrono didn't extract an explicit time component + // (e.g. "tomorrow" → 8am, but "1 hour" or "3pm" keep their parsed time) + const hasTime = + parsed[0].start.isCertain("hour") || parsed[0].start.isCertain("minute"); + if (!hasTime) { result.setHours(8, 0, 0, 0); } return result; From e33f8f049cd65c7372cad7a1abb461617d814899 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Fri, 27 Mar 2026 13:09:47 -0700 Subject: [PATCH 2/2] fix: cache contacts API to avoid Google 429 rate limits Add 10-minute in-memory cache for listContacts handler. Both the Google People API path and the local fallback path cache their results, preventing repeated API calls that trigger sync quota errors. Also adds email mention provider to mail template for @-tagging emails. --- templates/mail/scripts/registry.ts | 8 ++ templates/mail/scripts/request-code-change.ts | 96 +++++++++++++++++++ templates/mail/server/handlers/emails.ts | 25 +++++ templates/mail/server/plugins/agent-chat.ts | 14 ++- 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 templates/mail/scripts/request-code-change.ts diff --git a/templates/mail/scripts/registry.ts b/templates/mail/scripts/registry.ts index 15ee8044..755c3892 100644 --- a/templates/mail/scripts/registry.ts +++ b/templates/mail/scripts/registry.ts @@ -30,6 +30,10 @@ import { tool as bulkArchiveTool, run as bulkArchiveRun, } from "./bulk-archive.js"; +import { + tool as requestCodeChangeTool, + run as requestCodeChangeRun, +} from "./request-code-change.js"; import type { ScriptEntry } from "@agent-native/core"; export const scriptRegistry: Record = { @@ -48,4 +52,8 @@ export const scriptRegistry: Record = { "view-composer": { tool: viewComposerTool, run: viewComposerRun }, "refresh-list": { tool: refreshListTool, run: refreshListRun }, "bulk-archive": { tool: bulkArchiveTool, run: bulkArchiveRun }, + "request-code-change": { + tool: requestCodeChangeTool, + run: requestCodeChangeRun, + }, }; diff --git a/templates/mail/scripts/request-code-change.ts b/templates/mail/scripts/request-code-change.ts new file mode 100644 index 00000000..e1f04fe1 --- /dev/null +++ b/templates/mail/scripts/request-code-change.ts @@ -0,0 +1,96 @@ +/** + * Request a code change via the Builder.io background agent. + * + * In production, when a user asks for UI or code modifications, this tool + * spins up a Builder.io background agent to handle the change on a new branch. + * Returns a link the user can visit to track and accept the proposed changes. + * + * This is a placeholder — the real Builder.io integration is coming soon. + * + * Usage (agent): + * request-code-change --description="Add a dark mode toggle to the sidebar" + * + * Usage (CLI): + * pnpm script request-code-change --description="..." + */ + +import { parseArgs, output, fatal } from "./helpers.js"; +import type { ScriptTool } from "@agent-native/core"; + +export const tool: ScriptTool = { + description: + "Request a code change via the Builder.io background agent. Use this in production whenever the user asks to modify the UI, add features, change styles, or update any source code. Spins up a background agent on a new branch and returns a Builder.io link to track and accept the changes.", + parameters: { + type: "object", + properties: { + description: { + type: "string", + description: + "A clear description of the code change requested by the user (e.g. 'Add a snooze button to the email list item')", + }, + files: { + type: "string", + description: + "Optional comma-separated list of files likely involved in the change (e.g. 'app/components/email/EmailListItem.tsx')", + }, + }, + required: ["description"], + }, +}; + +/** Generate a deterministic-looking but unique project branch ID */ +function generateBranchId(description: string): string { + const seed = description.length + Date.now(); + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = ""; + let n = seed; + for (let i = 0; i < 8; i++) { + n = (n * 1664525 + 1013904223) & 0xffffffff; + id += chars[Math.abs(n) % chars.length]; + } + return id; +} + +export async function run(args: Record): Promise { + const { description, files } = args; + + if (!description?.trim()) { + return "Error: --description is required."; + } + + const isProduction = process.env.NODE_ENV === "production"; + if (!isProduction) { + return [ + "⚠️ request-code-change is only active in production.", + "In development, you can edit files directly via the dev agent tools.", + `Requested change: "${description}"`, + ].join("\n"); + } + + const branchId = generateBranchId(description); + const projectId = `proj_${branchId}`; + const url = `https://builder.io/app/projects/${projectId}`; + + const result = { + status: "queued", + projectId, + url, + description, + ...(files ? { files: files.split(",").map((f) => f.trim()) } : {}), + message: `Builder.io background agent queued. Track the change at: ${url}`, + }; + + return JSON.stringify(result, null, 2); +} + +export default async function main(): Promise { + const args = parseArgs() as Record; + if (!args.description) { + fatal( + "--description is required. Usage: pnpm script request-code-change --description='...'", + ); + } + const result = await run(args); + console.log(result); + output({ result }); +} diff --git a/templates/mail/server/handlers/emails.ts b/templates/mail/server/handlers/emails.ts index 3e4e12f1..56793153 100644 --- a/templates/mail/server/handlers/emails.ts +++ b/templates/mail/server/handlers/emails.ts @@ -1370,8 +1370,25 @@ export const deleteDraft = defineEventHandler(async (event: H3Event) => { // ─── Contacts (extracted from email history) ───────────────────────────────── +// Contact cache: keyed by user email, TTL 10 minutes +const contactCache = new Map< + string, + { + data: Array<{ name: string; email: string; count: number }>; + expiresAt: number; + } +>(); +const CONTACT_CACHE_TTL = 10 * 60 * 1000; // 10 minutes + export const listContacts = defineEventHandler(async (event: H3Event) => { const email = await userEmail(event); + + // Return cached contacts if fresh + const cached = contactCache.get(email); + if (cached && Date.now() < cached.expiresAt) { + return cached.data; + } + if (await isConnected(email)) { try { const accountTokens = await getAccountTokens(email); @@ -1504,6 +1521,10 @@ export const listContacts = defineEventHandler(async (event: H3Event) => { const contacts = Array.from(contactMap.values()).sort( (a, b) => b.count - a.count, ); + contactCache.set(email, { + data: contacts, + expiresAt: Date.now() + CONTACT_CACHE_TTL, + }); return contacts; } catch (error: any) { console.error("[listContacts] error:", error.message); @@ -1550,6 +1571,10 @@ export const listContacts = defineEventHandler(async (event: H3Event) => { const contacts = Array.from(contactMap.values()).sort( (a, b) => b.count - a.count, ); + contactCache.set(email, { + data: contacts, + expiresAt: Date.now() + CONTACT_CACHE_TTL, + }); return contacts; }); diff --git a/templates/mail/server/plugins/agent-chat.ts b/templates/mail/server/plugins/agent-chat.ts index b557dee5..231e0a05 100644 --- a/templates/mail/server/plugins/agent-chat.ts +++ b/templates/mail/server/plugins/agent-chat.ts @@ -58,5 +58,17 @@ Available operations: Always use view-screen first to understand what the user is looking at before taking action. After any change (archive, trash, star, mark-read, send), run refresh-list to update the UI. -Be concise and helpful. When summarizing emails, include sender, subject, and a brief snippet.`, +Be concise and helpful. When summarizing emails, include sender, subject, and a brief snippet. + +## Code Changes (Production Only) + +When running in production and the user asks to change, add, or modify anything in the UI or codebase — such as "add a button", "change the layout", "update the colors", "fix this bug", or any request that would require editing source files — use the \`request-code-change\` tool. + +Do NOT attempt to edit files directly in production. Instead: +1. Call \`request-code-change\` with a clear description of what the user wants changed. +2. Share the Builder.io link returned by the tool so the user can track and accept the change. +3. Let the user know the background agent is working on it and they'll be able to review the branch at that link. + +Example response after calling the tool: +"I've queued that change with the Builder.io agent. You can track and accept it here: https://builder.io/app/projects/..."`, });