Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/providers/EmailAccountProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function EmailAccountProvider({

useEffect(() => {
if (emailAccountId) {
setLastEmailAccountAction(emailAccountId);
setLastEmailAccountAction({ emailAccountId });
}
}, [emailAccountId]);

Expand Down
35 changes: 32 additions & 3 deletions apps/web/utils/account.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import { auth } from "@/utils/auth";
import {
getGmailClientWithRefresh,
Expand All @@ -9,8 +11,10 @@ import {
} from "@/utils/outlook/client";
import { redirect } from "next/navigation";
import prisma from "@/utils/prisma";
import { notFound } from "next/navigation";
import { getLastEmailAccountFromCookie } from "@/utils/actions/email-account-cookie";
import {
LAST_EMAIL_ACCOUNT_COOKIE,
type LastEmailAccountCookieValue,
} from "@/utils/cookies";

export async function getGmailClientForEmail({
emailAccountId,
Expand Down Expand Up @@ -118,7 +122,7 @@ export async function redirectToEmailAccountPath(path: `/${string}`) {
const userId = session?.user.id;
if (!userId) throw new Error("Not authenticated");

const lastEmailAccountId = await getLastEmailAccountFromCookie();
const lastEmailAccountId = await getLastEmailAccountFromCookie(userId);

let emailAccountId = lastEmailAccountId;

Expand All @@ -138,3 +142,28 @@ export async function redirectToEmailAccountPath(path: `/${string}`) {

redirect(redirectUrl);
}

async function getLastEmailAccountFromCookie(
userId: string,
): Promise<string | null> {
try {
const cookieStore = await cookies();
const cookieValue = cookieStore.get(LAST_EMAIL_ACCOUNT_COOKIE)?.value;
if (!cookieValue) return null;

// Handle backward compatibility: old cookies stored just the emailAccountId as a plain string
// New cookies store JSON with { userId, emailAccountId }
try {
const parsed = JSON.parse(cookieValue) as LastEmailAccountCookieValue;
// Validate userId matches to prevent stale data
if (parsed.userId !== userId) return null;
return parsed.emailAccountId;
} catch {
// If JSON parse fails, it's an old-format cookie (plain emailAccountId string)
// Return it as-is (the caller will still validate the user owns this account)
return cookieValue;
}
} catch {
return null;
}
}
50 changes: 34 additions & 16 deletions apps/web/utils/actions/email-account-cookie.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
"use server";

import { z } from "zod";
import { cookies } from "next/headers";
import { LAST_EMAIL_ACCOUNT_COOKIE } from "@/utils/cookies";
import {
LAST_EMAIL_ACCOUNT_COOKIE,
type LastEmailAccountCookieValue,
} from "@/utils/cookies";
import { actionClientUser } from "@/utils/actions/safe-action";

/**
* Sets a cookie with the last selected email account ID.
* This is used when emailAccountId is not provided in the URL.
*/
export async function setLastEmailAccountAction(emailAccountId: string) {
const cookieStore = await cookies();
export const setLastEmailAccountAction = actionClientUser
.metadata({ name: "setLastEmailAccount" })
.inputSchema(z.object({ emailAccountId: z.string() }))
.action(async ({ ctx: { userId }, parsedInput: { emailAccountId } }) => {
const cookieStore = await cookies();

cookieStore.set(LAST_EMAIL_ACCOUNT_COOKIE, emailAccountId, {
path: "/",
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: "lax",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
const cookieValue: LastEmailAccountCookieValue = {
userId,
emailAccountId,
};
const value = JSON.stringify(cookieValue);

cookieStore.set(LAST_EMAIL_ACCOUNT_COOKIE, value, {
path: "/",
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: "lax",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
});
});
}

// Not secure. Only used for redirects. Still requires checking user owns the account.
export async function getLastEmailAccountFromCookie(): Promise<string | null> {
const cookieStore = await cookies();
const value = cookieStore.get(LAST_EMAIL_ACCOUNT_COOKIE)?.value;
return value || null;
}
/**
* Clears the last email account cookie.
* Called on logout to prevent stale account IDs when switching users.
*/
export const clearLastEmailAccountAction = actionClientUser
.metadata({ name: "clearLastEmailAccount" })
.action(async () => {
const cookieStore = await cookies();
cookieStore.delete(LAST_EMAIL_ACCOUNT_COOKIE);
});
5 changes: 5 additions & 0 deletions apps/web/utils/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ export const REPLY_ZERO_ONBOARDING_COOKIE = "viewed_reply_zero_onboarding";
export const INVITATION_COOKIE = "invitation_id";
export const LAST_EMAIL_ACCOUNT_COOKIE = "last_email_account_id";

export type LastEmailAccountCookieValue = {
userId: string;
emailAccountId: string;
};

export function markOnboardingAsCompleted(cookie: string) {
document.cookie = `${cookie}=true; path=/; max-age=${Number.MAX_SAFE_INTEGER}; SameSite=Lax`;
}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/utils/user.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"use client";

import { signOut } from "@/utils/auth-client";
import { clearLastEmailAccountAction } from "@/utils/actions/email-account-cookie";

export async function logOut(callbackUrl?: string) {
clearLastEmailAccountAction();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing await causes race condition.

clearLastEmailAccountAction() is called without await, making it fire-and-forget. This creates a race condition where the cookie may not be cleared before signOut completes, defeating the PR's purpose of preventing stale account IDs. Additionally, any errors during cookie deletion will be silently ignored.

Apply this diff to properly await the cookie clearing:

 export async function logOut(callbackUrl?: string) {
-  clearLastEmailAccountAction();
+  await clearLastEmailAccountAction();

   await signOut({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clearLastEmailAccountAction();
export async function logOut(callbackUrl?: string) {
await clearLastEmailAccountAction();
await signOut({
🤖 Prompt for AI Agents
In apps/web/utils/user.ts around line 7, clearLastEmailAccountAction() is being
invoked without awaiting, causing a race where the cookie may not be cleared
before signOut and any deletion errors are ignored; update the call to await
clearLastEmailAccountAction() from an async context (or mark the caller async)
and wrap it in try/catch so any errors are logged/handled instead of being
swallowed, ensuring the cookie deletion completes before proceeding.

Comment thread
elie222 marked this conversation as resolved.

await signOut({
fetchOptions: {
onSuccess: () => {
Expand Down
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.17.20
v2.17.21
Loading