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/ai/meeting-briefs/generate-briefing.test.ts b/apps/web/utils/ai/meeting-briefs/generate-briefing.test.ts index 19c3a299dc..3415af4a36 100644 --- a/apps/web/utils/ai/meeting-briefs/generate-briefing.test.ts +++ b/apps/web/utils/ai/meeting-briefs/generate-briefing.test.ts @@ -1,7 +1,13 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import type { MeetingBriefingData } from "@/utils/meeting-briefs/gather-context"; vi.mock("server-only", () => ({})); +vi.mock("@/env", () => ({ + env: { + PERPLEXITY_API_KEY: "test-key", + DEFAULT_LLM_PROVIDER: "openai", + }, +})); vi.mock("@/utils/llms/model", () => ({ getModel: vi.fn() })); vi.mock("@/utils/llms", () => ({ createGenerateObject: vi.fn() })); vi.mock("@/utils/stringify-email", () => ({ @@ -22,6 +28,10 @@ vi.doUnmock("@/utils/date"); import { buildPrompt } from "./generate-briefing"; +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("buildPrompt timezone handling", () => { it("formats past meeting times in the user's timezone (not UTC)", () => { // This test documents the timezone bug fix: diff --git a/apps/web/utils/auth.test.ts b/apps/web/utils/auth.test.ts index f8dc2ac0cc..cbd8bdefd1 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().mockResolvedValue(undefined), + clearUserErrorMessages: vi.fn().mockResolvedValue(undefined), + 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..ef8a1b8aa2 --- /dev/null +++ b/apps/web/utils/auth/cleanup-invalid-tokens.test.ts @@ -0,0 +1,116 @@ +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().mockResolvedValue(undefined), + 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", + userId: "user_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); + prisma.account.updateMany.mockResolvedValue({ count: 1 }); + + await cleanupInvalidTokens({ + emailAccountId: "ea_1", + reason: "invalid_grant", + logger, + }); + + expect(prisma.account.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "acc_1", disconnectedAt: null }, + data: expect.objectContaining({ + disconnectedAt: expect.any(Date), + }), + }), + ); + expect(sendReconnectionEmail).toHaveBeenCalled(); + expect(addUserErrorMessage).toHaveBeenCalledWith( + "user_1", + "Account disconnected", + expect.stringContaining("test@example.com"), + ); + }); + + it("marks as disconnected but skips email if account is not watched", async () => { + prisma.emailAccount.findUnique.mockResolvedValue({ + ...mockEmailAccount, + watchEmailsExpirationDate: null, + } as any); + prisma.account.updateMany.mockResolvedValue({ count: 1 }); + + await cleanupInvalidTokens({ + emailAccountId: "ea_1", + reason: "invalid_grant", + logger, + }); + + expect(prisma.account.updateMany).toHaveBeenCalled(); + expect(sendReconnectionEmail).not.toHaveBeenCalled(); + expect(addUserErrorMessage).toHaveBeenCalledWith( + "user_1", + "Account disconnected", + expect.stringContaining("test@example.com"), + ); + }); + + 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.updateMany).not.toHaveBeenCalled(); + expect(sendReconnectionEmail).not.toHaveBeenCalled(); + }); + + it("does not send email for insufficient_permissions", async () => { + prisma.emailAccount.findUnique.mockResolvedValue(mockEmailAccount as any); + prisma.account.updateMany.mockResolvedValue({ count: 1 }); + + await cleanupInvalidTokens({ + emailAccountId: "ea_1", + reason: "insufficient_permissions", + logger, + }); + + expect(prisma.account.updateMany).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..f14c739dd0 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,18 @@ export async function cleanupInvalidTokens({ const emailAccount = await prisma.emailAccount.findUnique({ where: { id: emailAccountId }, - select: { accountId: true }, + select: { + id: true, + email: true, + accountId: true, + userId: true, + watchEmailsExpirationDate: true, + account: { + select: { + disconnectedAt: true, + }, + }, + }, }); if (!emailAccount) { @@ -28,14 +43,67 @@ export async function cleanupInvalidTokens({ return; } - await prisma.account.update({ - where: { id: emailAccount.accountId }, + if (emailAccount.account?.disconnectedAt) { + logger.info("Account already marked as disconnected"); + return; + } + + const updated = await prisma.account.updateMany({ + where: { id: emailAccount.accountId, disconnectedAt: null }, data: { access_token: null, refresh_token: null, expires_at: null, + disconnectedAt: new Date(), }, }); + if (updated.count === 0) { + logger.info( + "Account already marked as disconnected (via concurrent update)", + ); + return; + } + + if (reason === "invalid_grant") { + const isWatched = + !!emailAccount.watchEmailsExpirationDate && + emailAccount.watchEmailsExpirationDate > new Date(); + + 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.userId, + 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..4b373e1eea 100644 --- a/apps/web/utils/error-messages/index.ts +++ b/apps/web/utils/error-messages/index.ts @@ -1,5 +1,6 @@ import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; +import { captureException } from "@/utils/error"; const logger = createScopedLogger("error-messages"); @@ -23,13 +24,13 @@ export async function getUserErrorMessages( } export async function addUserErrorMessage( - userEmail: string, + userId: string, errorType: (typeof ErrorType)[keyof typeof ErrorType], errorMessage: string, ): Promise { - const user = await prisma.user.findUnique({ where: { email: userEmail } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { - logger.warn("User not found", { userEmail }); + logger.warn("User not found", { userId }); return; } @@ -54,10 +55,18 @@ export async function clearUserErrorMessages({ }: { userId: string; }): Promise { - await prisma.user.update({ - where: { id: userId }, - data: { errorMessages: {} }, - }); + try { + await prisma.user.update({ + where: { id: userId }, + data: { errorMessages: {} }, + }); + } catch (error) { + logger.error("Error clearing user error messages:", { + userId, + error, + }); + captureException(error, { extra: { userId } }); + } } export const ErrorType = { @@ -66,4 +75,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/llms/index.ts b/apps/web/utils/llms/index.ts index 4bd1f765ed..06ea254c7b 100644 --- a/apps/web/utils/llms/index.ts +++ b/apps/web/utils/llms/index.ts @@ -48,7 +48,7 @@ export function createGenerateText({ label, modelOptions, }: { - emailAccount: Pick; + emailAccount: Pick; label: string; modelOptions: ReturnType; }): typeof generateText { @@ -115,6 +115,7 @@ export function createGenerateText({ } catch (backupError) { await handleError( backupError, + emailAccount.userId, emailAccount.email, emailAccount.id, label, @@ -126,6 +127,7 @@ export function createGenerateText({ await handleError( error, + emailAccount.userId, emailAccount.email, emailAccount.id, label, @@ -141,7 +143,7 @@ export function createGenerateObject({ label, modelOptions, }: { - emailAccount: Pick; + emailAccount: Pick; label: string; modelOptions: ReturnType; }): typeof generateObject { @@ -206,6 +208,7 @@ export function createGenerateObject({ } catch (error) { await handleError( error, + emailAccount.userId, emailAccount.email, emailAccount.id, label, @@ -295,6 +298,7 @@ export async function chatCompletionStream({ async function handleError( error: unknown, + userId: string, userEmail: string, emailAccountId: string, label: string, @@ -302,6 +306,7 @@ async function handleError( ) { logger.error("Error in LLM call", { error, + userId, userEmail, emailAccountId, label, @@ -311,7 +316,7 @@ async function handleError( if (APICallError.isInstance(error)) { if (isIncorrectOpenAIAPIKeyError(error)) { return await addUserErrorMessage( - userEmail, + userId, ErrorType.INCORRECT_OPENAI_API_KEY, error.message, ); @@ -319,7 +324,7 @@ async function handleError( if (isInvalidOpenAIModelError(error)) { return await addUserErrorMessage( - userEmail, + userId, ErrorType.INVALID_OPENAI_MODEL, error.message, ); @@ -327,7 +332,7 @@ async function handleError( if (isOpenAIAPIKeyDeactivatedError(error)) { return await addUserErrorMessage( - userEmail, + userId, ErrorType.OPENAI_API_KEY_DEACTIVATED, error.message, ); @@ -335,7 +340,7 @@ async function handleError( if (RetryError.isInstance(error) && isOpenAIRetryError(error)) { return await addUserErrorMessage( - userEmail, + userId, ErrorType.OPENAI_RETRY_ERROR, error.message, ); @@ -343,7 +348,7 @@ async function handleError( if (isAnthropicInsufficientBalanceError(error)) { return await addUserErrorMessage( - userEmail, + userId, ErrorType.ANTHROPIC_INSUFFICIENT_BALANCE, error.message, ); diff --git a/apps/web/utils/meeting-briefs/recipient-context.test.ts b/apps/web/utils/meeting-briefs/recipient-context.test.ts index 3200e5384b..5c8a688060 100644 --- a/apps/web/utils/meeting-briefs/recipient-context.test.ts +++ b/apps/web/utils/meeting-briefs/recipient-context.test.ts @@ -12,7 +12,7 @@ import type { Logger } from "@/utils/logger"; vi.mock("@/utils/calendar/event-provider"); vi.mock("@/utils/date", () => ({ formatInUserTimezone: vi.fn((date: Date) => { - // Simple mock that returns a predictable format for testing + // Simple mock that returns a predictable format for testing in UTC const d = new Date(date); const dayNames = [ "Sunday", @@ -37,11 +37,11 @@ vi.mock("@/utils/date", () => ({ "November", "December", ]; - const dayName = dayNames[d.getDay()]; - const month = monthNames[d.getMonth()]; - const day = d.getDate(); - const hours = d.getHours(); - const minutes = d.getMinutes(); + const dayName = dayNames[d.getUTCDay()]; + const month = monthNames[d.getUTCMonth()]; + const day = d.getUTCDate(); + const hours = d.getUTCHours(); + const minutes = d.getUTCMinutes(); const ampm = hours >= 12 ? "PM" : "AM"; const displayHours = hours % 12 || 12; const displayMinutes = minutes.toString().padStart(2, "0"); @@ -100,9 +100,9 @@ describe("recipient-context", () => { expect(result).toBe(`You have meeting history with this person: -- "Q1 Planning Meeting" on Monday, January 15 at 12:00 PM (Conference Room A) +- "Q1 Planning Meeting" on Monday, January 15 at 10:00 AM (Conference Room A) Description: Discuss Q1 goals and objectives -- "Team Standup" on Wednesday, January 10 at 11:00 AM +- "Team Standup" on Wednesday, January 10 at 9:00 AM Use this context naturally if relevant. For past meetings, you might reference topics discussed.`); @@ -128,7 +128,7 @@ Use this context naturally if relevant. For past meetings, you might reference t expect(result).toBe(`You have meeting history with this person: -- "Product Review" on Thursday, February 1 at 4:00 PM (Zoom) +- "Product Review" on Thursday, February 1 at 2:00 PM (Zoom) Description: Review product roadmap and features @@ -160,12 +160,12 @@ Use this context naturally if relevant. For upcoming meetings, you might say "Lo expect(result).toBe(`You have meeting history with this person: -- "Past Meeting" on Monday, January 15 at 12:00 PM +- "Past Meeting" on Monday, January 15 at 10:00 AM Description: This is a past meeting description -- "Upcoming Meeting" on Thursday, February 1 at 4:00 PM (Office) +- "Upcoming Meeting" on Thursday, February 1 at 2:00 PM (Office) Description: This is an upcoming meeting description @@ -231,7 +231,7 @@ Use this context naturally if relevant. For past meetings, you might reference t expect(result).toBe(`You have meeting history with this person: -- "Simple Meeting" on Monday, January 15 at 12:00 PM +- "Simple Meeting" on Monday, January 15 at 10:00 AM Use this context naturally if relevant. For past meetings, you might reference topics discussed.`); 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..44b29e81f1 --- /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 */} +
+