From b2bf3aaf669429d916e62824f2aa922a3ecca78c Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:43:41 +0200 Subject: [PATCH 1/3] feat: handle invalid_grant errors with reconnection emails --- .../migration.sql | 2 + apps/web/prisma/schema.prisma | 1 + apps/web/utils/auth.test.ts | 91 ++++++++++- apps/web/utils/auth.ts | 37 +++-- .../utils/auth/cleanup-invalid-tokens.test.ts | 104 +++++++++++++ apps/web/utils/auth/cleanup-invalid-tokens.ts | 60 +++++++- apps/web/utils/email/reply-all.test.ts | 6 +- apps/web/utils/email/reply-all.ts | 4 +- apps/web/utils/email/watch-manager.ts | 2 + apps/web/utils/error-messages/index.ts | 1 + .../webhook/validate-webhook-account.test.ts | 26 +++- .../utils/webhook/validate-webhook-account.ts | 6 + packages/resend/emails/reconnection.tsx | 145 ++++++++++++++++++ packages/resend/src/send.tsx | 30 ++++ 14 files changed, 496 insertions(+), 19 deletions(-) create mode 100644 apps/web/prisma/migrations/20260101221942_account_disconnected_at/migration.sql create mode 100644 apps/web/utils/auth/cleanup-invalid-tokens.test.ts create mode 100644 packages/resend/emails/reconnection.tsx diff --git a/apps/web/prisma/migrations/20260101221942_account_disconnected_at/migration.sql b/apps/web/prisma/migrations/20260101221942_account_disconnected_at/migration.sql new file mode 100644 index 0000000000..8b56b76964 --- /dev/null +++ b/apps/web/prisma/migrations/20260101221942_account_disconnected_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "disconnectedAt" TIMESTAMP(3); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index f5047bcea3..3cd1d88e14 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -25,6 +25,7 @@ model Account { refreshTokenExpiresAt DateTime? access_token String? @db.Text expires_at DateTime? @default(now()) + disconnectedAt DateTime? // When OAuth tokens were invalidated (password change, revoked access) token_type String? scope String? id_token String? @db.Text diff --git a/apps/web/utils/auth.test.ts b/apps/web/utils/auth.test.ts index f8dc2ac0cc..09dd49754a 100644 --- a/apps/web/utils/auth.test.ts +++ b/apps/web/utils/auth.test.ts @@ -2,10 +2,30 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { cookies } from "next/headers"; import { createReferral } from "@/utils/referral/referral-code"; import { captureException } from "@/utils/error"; -import { handleReferralOnSignUp } from "@/utils/auth"; +import { handleReferralOnSignUp, saveTokens } from "@/utils/auth"; +import prisma from "@/utils/__mocks__/prisma"; +import { clearUserErrorMessages } from "@/utils/error-messages"; -// Mock the dependencies vi.mock("server-only", () => ({})); +vi.mock("@/utils/prisma"); +vi.mock("@/utils/error-messages", () => ({ + addUserErrorMessage: vi.fn(), + clearUserErrorMessages: vi.fn(), + ErrorType: { + ACCOUNT_DISCONNECTED: "Account disconnected", + }, +})); +vi.mock("@googleapis/people", () => ({ + people: vi.fn(), +})); +vi.mock("@googleapis/gmail", () => ({ + auth: { + OAuth2: vi.fn(), + }, +})); +vi.mock("@/utils/encryption", () => ({ + encryptToken: vi.fn((t) => t), +})); vi.mock("next/headers", () => ({ cookies: vi.fn(), @@ -19,8 +39,6 @@ vi.mock("@/utils/error", () => ({ captureException: vi.fn(), })); -// Import the real function from auth.ts for testing - describe("handleReferralOnSignUp", () => { const mockCookies = vi.mocked(cookies); const mockCreateReferral = vi.mocked(createReferral); @@ -94,3 +112,68 @@ describe("handleReferralOnSignUp", () => { expect(mockCreateReferral).not.toHaveBeenCalled(); }); }); + +describe("saveTokens", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clears disconnectedAt and error messages when saving tokens via emailAccountId", async () => { + prisma.emailAccount.update.mockResolvedValue({ userId: "user_1" } as any); + + await saveTokens({ + emailAccountId: "ea_1", + tokens: { + access_token: "new-access", + refresh_token: "new-refresh", + expires_at: 123_456_789, + }, + accountRefreshToken: null, + provider: "google", + }); + + expect(prisma.emailAccount.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "ea_1" }, + data: expect.objectContaining({ + account: { + update: expect.objectContaining({ + disconnectedAt: null, + }), + }, + }), + }), + ); + expect(clearUserErrorMessages).toHaveBeenCalledWith({ userId: "user_1" }); + }); + + it("clears disconnectedAt and error messages when saving tokens via providerAccountId", async () => { + prisma.account.update.mockResolvedValue({ userId: "user_1" } as any); + + await saveTokens({ + providerAccountId: "pa_1", + tokens: { + access_token: "new-access", + refresh_token: "new-refresh", + expires_at: 123_456_789, + }, + accountRefreshToken: null, + provider: "google", + }); + + expect(prisma.account.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + provider_providerAccountId: { + provider: "google", + providerAccountId: "pa_1", + }, + }), + data: expect.objectContaining({ + disconnectedAt: null, + }), + }), + ); + expect(clearUserErrorMessages).toHaveBeenCalledWith({ userId: "user_1" }); + }); +}); diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 8295e6b544..c7c3ab0ebe 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -25,6 +25,7 @@ import { claimPendingPremiumInvite, updateAccountSeats, } from "@/utils/premium/server"; +import { clearUserErrorMessages } from "@/utils/error-messages"; import prisma from "@/utils/prisma"; const logger = createScopedLogger("auth"); @@ -402,14 +403,22 @@ async function handleLinkAccount(account: Account) { image: primaryPhotoUrl, }; - await prisma.emailAccount.upsert({ - where: { email: profileData?.email }, - update: data, - create: { - ...data, - email: primaryEmail, - }, - }); + await prisma.$transaction([ + prisma.emailAccount.upsert({ + where: { email: profileData?.email }, + update: data, + create: { + ...data, + email: primaryEmail, + }, + }), + prisma.account.update({ + where: { id: account.id }, + data: { disconnectedAt: null }, + }), + ]); + + await clearUserErrorMessages({ userId: account.userId }); // Handle premium account seats await updateAccountSeats({ userId: account.userId }).catch((error) => { @@ -475,6 +484,7 @@ export async function saveTokens({ access_token: tokens.access_token, expires_at: tokens.expires_at ? new Date(tokens.expires_at * 1000) : null, refresh_token: refreshToken, + disconnectedAt: null, }; if (emailAccountId) { @@ -486,10 +496,13 @@ export async function saveTokens({ if (data.refresh_token) data.refresh_token = encryptToken(data.refresh_token) || ""; - await prisma.emailAccount.update({ + const emailAccount = await prisma.emailAccount.update({ where: { id: emailAccountId }, data: { account: { update: data } }, + select: { userId: true }, }); + + await clearUserErrorMessages({ userId: emailAccount.userId }); } else { if (!providerAccountId) { logger.error("No providerAccountId found in database", { @@ -501,7 +514,7 @@ export async function saveTokens({ return; } - return await prisma.account.update({ + const account = await prisma.account.update({ where: { provider_providerAccountId: { provider, @@ -510,6 +523,10 @@ export async function saveTokens({ }, data, }); + + await clearUserErrorMessages({ userId: account.userId }); + + return account; } } diff --git a/apps/web/utils/auth/cleanup-invalid-tokens.test.ts b/apps/web/utils/auth/cleanup-invalid-tokens.test.ts new file mode 100644 index 0000000000..752b3a28f7 --- /dev/null +++ b/apps/web/utils/auth/cleanup-invalid-tokens.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import prisma from "@/utils/__mocks__/prisma"; +import { cleanupInvalidTokens } from "./cleanup-invalid-tokens"; +import { sendReconnectionEmail } from "@inboxzero/resend"; +import { createScopedLogger } from "@/utils/logger"; +import { addUserErrorMessage } from "@/utils/error-messages"; + +vi.mock("@/utils/prisma"); +vi.mock("@inboxzero/resend", () => ({ + sendReconnectionEmail: vi.fn(), +})); +vi.mock("@/utils/error-messages", () => ({ + addUserErrorMessage: vi.fn(), + ErrorType: { + ACCOUNT_DISCONNECTED: "Account disconnected", + }, +})); +vi.mock("@/utils/unsubscribe", () => ({ + createUnsubscribeToken: vi.fn().mockResolvedValue("mock-token"), +})); + +describe("cleanupInvalidTokens", () => { + const logger = createScopedLogger("test"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockEmailAccount = { + id: "ea_1", + email: "test@example.com", + accountId: "acc_1", + account: { disconnectedAt: null }, + watchEmailsExpirationDate: new Date(Date.now() + 1000 * 60 * 60), // Valid expiration + }; + + it("marks account as disconnected and sends email on invalid_grant when account is watched", async () => { + prisma.emailAccount.findUnique.mockResolvedValue(mockEmailAccount as any); + + await cleanupInvalidTokens({ + emailAccountId: "ea_1", + reason: "invalid_grant", + logger, + }); + + expect(prisma.account.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "acc_1" }, + data: expect.objectContaining({ + disconnectedAt: expect.any(Date), + }), + }), + ); + expect(sendReconnectionEmail).toHaveBeenCalled(); + expect(addUserErrorMessage).toHaveBeenCalled(); + }); + + it("marks as disconnected but skips email if account is not watched", async () => { + prisma.emailAccount.findUnique.mockResolvedValue({ + ...mockEmailAccount, + watchEmailsExpirationDate: null, + } as any); + + await cleanupInvalidTokens({ + emailAccountId: "ea_1", + reason: "invalid_grant", + logger, + }); + + expect(prisma.account.update).toHaveBeenCalled(); + expect(sendReconnectionEmail).not.toHaveBeenCalled(); + expect(addUserErrorMessage).toHaveBeenCalled(); + }); + + it("returns early if account is already disconnected", async () => { + prisma.emailAccount.findUnique.mockResolvedValue({ + ...mockEmailAccount, + account: { disconnectedAt: new Date() }, + } as any); + + await cleanupInvalidTokens({ + emailAccountId: "ea_1", + reason: "invalid_grant", + logger, + }); + + expect(prisma.account.update).not.toHaveBeenCalled(); + expect(sendReconnectionEmail).not.toHaveBeenCalled(); + }); + + it("does not send email for insufficient_permissions", async () => { + prisma.emailAccount.findUnique.mockResolvedValue(mockEmailAccount as any); + + await cleanupInvalidTokens({ + emailAccountId: "ea_1", + reason: "insufficient_permissions", + logger, + }); + + expect(prisma.account.update).toHaveBeenCalled(); + expect(sendReconnectionEmail).not.toHaveBeenCalled(); + expect(addUserErrorMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/utils/auth/cleanup-invalid-tokens.ts b/apps/web/utils/auth/cleanup-invalid-tokens.ts index 67360b36af..d18289764d 100644 --- a/apps/web/utils/auth/cleanup-invalid-tokens.ts +++ b/apps/web/utils/auth/cleanup-invalid-tokens.ts @@ -1,5 +1,9 @@ import prisma from "@/utils/prisma"; import type { Logger } from "@/utils/logger"; +import { sendReconnectionEmail } from "@inboxzero/resend"; +import { env } from "@/env"; +import { addUserErrorMessage, ErrorType } from "@/utils/error-messages"; +import { createUnsubscribeToken } from "@/utils/unsubscribe"; /** * Cleans up invalid tokens when authentication fails permanently. @@ -20,7 +24,17 @@ export async function cleanupInvalidTokens({ const emailAccount = await prisma.emailAccount.findUnique({ where: { id: emailAccountId }, - select: { accountId: true }, + select: { + id: true, + email: true, + accountId: true, + watchEmailsExpirationDate: true, + account: { + select: { + disconnectedAt: true, + }, + }, + }, }); if (!emailAccount) { @@ -28,14 +42,58 @@ export async function cleanupInvalidTokens({ return; } + if (emailAccount.account?.disconnectedAt) { + logger.info("Account already marked as disconnected"); + return; + } + await prisma.account.update({ where: { id: emailAccount.accountId }, data: { access_token: null, refresh_token: null, expires_at: null, + disconnectedAt: new Date(), }, }); + if (reason === "invalid_grant") { + const isWatched = !!emailAccount.watchEmailsExpirationDate; + + if (isWatched) { + try { + const unsubscribeToken = await createUnsubscribeToken({ + emailAccountId: emailAccount.id, + }); + + await sendReconnectionEmail({ + from: env.RESEND_FROM_EMAIL, + to: emailAccount.email, + emailProps: { + baseUrl: env.NEXT_PUBLIC_BASE_URL, + email: emailAccount.email, + unsubscribeToken, + }, + }); + logger.info("Reconnection email sent", { email: emailAccount.email }); + } catch (error) { + logger.error("Failed to send reconnection email", { + email: emailAccount.email, + error, + }); + } + } else { + logger.info( + "Skipping reconnection email - account not currently watched", + ); + } + + await addUserErrorMessage( + emailAccount.email, + ErrorType.ACCOUNT_DISCONNECTED, + `The connection for ${emailAccount.email} was disconnected. Please reconnect your account to resume automation.`, + ); + } + logger.info("Tokens cleared - user must re-authenticate", { reason }); } diff --git a/apps/web/utils/email/reply-all.test.ts b/apps/web/utils/email/reply-all.test.ts index 73327c73f5..75029fdf01 100644 --- a/apps/web/utils/email/reply-all.test.ts +++ b/apps/web/utils/email/reply-all.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { buildReplyAllRecipients, formatCcList, mergeAndDedupeRecipients } from "./reply-all"; +import { + buildReplyAllRecipients, + formatCcList, + mergeAndDedupeRecipients, +} from "./reply-all"; import type { ParsedMessageHeaders } from "@/utils/types"; describe("buildReplyAllRecipients", () => { diff --git a/apps/web/utils/email/reply-all.ts b/apps/web/utils/email/reply-all.ts index ee2db64f4f..6c70caf734 100644 --- a/apps/web/utils/email/reply-all.ts +++ b/apps/web/utils/email/reply-all.ts @@ -96,7 +96,9 @@ export function mergeAndDedupeRecipients( manual: string | undefined, ): string[] { const result = [...existing]; - const seen = new Set(existing.map((e) => extractEmailAddress(e).toLowerCase())); + const seen = new Set( + existing.map((e) => extractEmailAddress(e).toLowerCase()), + ); if (manual) { const manualEntries = manual diff --git a/apps/web/utils/email/watch-manager.ts b/apps/web/utils/email/watch-manager.ts index 606772b350..118ab9589d 100644 --- a/apps/web/utils/email/watch-manager.ts +++ b/apps/web/utils/email/watch-manager.ts @@ -37,6 +37,7 @@ async function getEmailAccountsToWatch(userIds: string[] | null) { where: { ...(userIds ? { userId: { in: userIds } } : {}), ...getPremiumUserFilter(), + account: { disconnectedAt: null }, }, select: { id: true, @@ -49,6 +50,7 @@ async function getEmailAccountsToWatch(userIds: string[] | null) { access_token: true, refresh_token: true, expires_at: true, + disconnectedAt: true, }, }, user: { diff --git a/apps/web/utils/error-messages/index.ts b/apps/web/utils/error-messages/index.ts index 8dd0bf2865..5933fa70cc 100644 --- a/apps/web/utils/error-messages/index.ts +++ b/apps/web/utils/error-messages/index.ts @@ -66,4 +66,5 @@ export const ErrorType = { OPENAI_API_KEY_DEACTIVATED: "OpenAI API key deactivated", OPENAI_RETRY_ERROR: "OpenAI retry error", ANTHROPIC_INSUFFICIENT_BALANCE: "Anthropic insufficient balance", + ACCOUNT_DISCONNECTED: "Account disconnected", }; diff --git a/apps/web/utils/webhook/validate-webhook-account.test.ts b/apps/web/utils/webhook/validate-webhook-account.test.ts index 7649e6c51c..6d1a282d48 100644 --- a/apps/web/utils/webhook/validate-webhook-account.test.ts +++ b/apps/web/utils/webhook/validate-webhook-account.test.ts @@ -6,7 +6,6 @@ import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("test"); -// Mock dependencies vi.mock("@/utils/premium"); vi.mock("@/app/api/watch/controller"); vi.mock("@/utils/email/provider"); @@ -14,7 +13,6 @@ vi.mock("@/utils/email/watch-manager"); vi.mock("@/utils/prisma"); vi.mock("server-only", () => ({})); -// Import mocked functions import { isPremium, hasAiAccess } from "@/utils/premium"; import { unwatchEmails } from "@/utils/email/watch-manager"; import { createEmailProvider } from "@/utils/email/provider"; @@ -48,6 +46,7 @@ describe("validateWebhookAccount", () => { access_token: "access-token", refresh_token: "refresh-token", expires_at: new Date(), + disconnectedAt: null, }, rules: [ { @@ -97,6 +96,27 @@ describe("validateWebhookAccount", () => { }); }); + describe("when account is disconnected", () => { + it("should return failure with 200 OK early", async () => { + const emailAccount = createMockEmailAccount({ + account: { + provider: "google", + access_token: "access-token", + refresh_token: "refresh-token", + expires_at: new Date(), + disconnectedAt: new Date(), + }, + }); + + const result = await validateWebhookAccount(emailAccount, logger); + + expect(result.success).toBe(false); + if (!result.success) { + expect(await result.response.json()).toEqual({ ok: true }); + } + }); + }); + describe("when account is not premium", () => { it("should unwatch emails and return failure", async () => { const emailAccount = createMockEmailAccount({ @@ -181,6 +201,7 @@ describe("validateWebhookAccount", () => { access_token: null, refresh_token: "refresh-token", expires_at: new Date(), + disconnectedAt: null, }, }); @@ -204,6 +225,7 @@ describe("validateWebhookAccount", () => { access_token: "access-token", refresh_token: null, expires_at: new Date(), + disconnectedAt: null, }, }); diff --git a/apps/web/utils/webhook/validate-webhook-account.ts b/apps/web/utils/webhook/validate-webhook-account.ts index f21d9ec3e4..7c2fe24223 100644 --- a/apps/web/utils/webhook/validate-webhook-account.ts +++ b/apps/web/utils/webhook/validate-webhook-account.ts @@ -29,6 +29,7 @@ export async function getWebhookEmailAccount( access_token: true, refresh_token: true, expires_at: true, + disconnectedAt: true, }, }, rules: { @@ -123,6 +124,11 @@ export async function validateWebhookAccount( return { success: false, response: NextResponse.json({ ok: true }) }; } + if (emailAccount.account?.disconnectedAt) { + logger.info("Skipping disconnected account"); + return { success: false, response: NextResponse.json({ ok: true }) }; + } + const premium = env.NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS ? { tier: "BUSINESS_PLUS_ANNUALLY" as const } : isPremium( diff --git a/packages/resend/emails/reconnection.tsx b/packages/resend/emails/reconnection.tsx new file mode 100644 index 0000000000..9df8be649c --- /dev/null +++ b/packages/resend/emails/reconnection.tsx @@ -0,0 +1,145 @@ +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Img, + Link, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import type { FC } from "react"; + +export type ReconnectionEmailProps = { + baseUrl: string; + email: string; + unsubscribeToken: string; +}; + +type ReconnectionEmailComponent = FC & { + PreviewProps: ReconnectionEmailProps; +}; + +const ReconnectionEmail: ReconnectionEmailComponent = ({ + baseUrl = "https://www.getinboxzero.com", + email, + unsubscribeToken, +}: ReconnectionEmailProps) => { + const reconnectUrl = `${baseUrl}/accounts`; + + return ( + + + + + + {/* Header */} +
+ + Inbox Zero + + + + + Inbox Zero + + + + + Action Required: Your email account was disconnected + +
+ + {/* Main Content */} +
+ Hi, + + + The connection for {email} to Inbox Zero was + disconnected. This usually happens after a password change, if + access was revoked, or if your 6-month approval period has + expired. + + + + Please reconnect your account to resume your automated email + rules and AI assistant features. + + + {/* CTA Button */} +
+ +
+ + + If you didn't expect this, it's likely a security measure from + your email provider. Reconnecting is safe and only takes a few + seconds. + +
+ + {/* Footer */} +
+