diff --git a/apps/web/utils/actions/error-messages.ts b/apps/web/utils/actions/error-messages.ts index 072374bc06..73c5f7e9c2 100644 --- a/apps/web/utils/actions/error-messages.ts +++ b/apps/web/utils/actions/error-messages.ts @@ -6,7 +6,7 @@ import { actionClientUser } from "@/utils/actions/safe-action"; export const clearUserErrorMessagesAction = actionClientUser .metadata({ name: "clearUserErrorMessages" }) - .action(async ({ ctx: { userId } }) => { - await clearUserErrorMessages({ userId }); + .action(async ({ ctx: { userId, logger } }) => { + await clearUserErrorMessages({ userId, logger }); revalidatePath("/(app)", "layout"); }); diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index d140e95f62..8f319f099d 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -13,6 +13,7 @@ import { calculateNextScheduleDate } from "@/utils/schedule"; import { actionClientUser } from "@/utils/actions/safe-action"; import { ActionType } from "@/generated/prisma/enums"; import type { Prisma } from "@/generated/prisma/client"; +import { clearSpecificErrorMessages, ErrorType } from "@/utils/error-messages"; export const updateEmailSettingsAction = actionClient .metadata({ name: "updateEmailSettings" }) @@ -37,7 +38,7 @@ export const updateAiSettingsAction = actionClientUser .inputSchema(saveAiSettingsBody) .action( async ({ - ctx: { userId }, + ctx: { userId, logger }, parsedInput: { aiProvider, aiModel, aiApiKey }, }) => { await prisma.user.update({ @@ -47,6 +48,20 @@ export const updateAiSettingsAction = actionClientUser ? { aiProvider: null, aiModel: null, aiApiKey: null } : { aiProvider, aiModel, aiApiKey }, }); + + // Clear AI-related error messages when user updates their settings + // This allows them to be notified again if the new settings are also invalid + await clearSpecificErrorMessages({ + userId, + errorTypes: [ + ErrorType.INCORRECT_OPENAI_API_KEY, + ErrorType.INVALID_OPENAI_MODEL, + ErrorType.OPENAI_API_KEY_DEACTIVATED, + ErrorType.OPENAI_RETRY_ERROR, + ErrorType.ANTHROPIC_INSUFFICIENT_BALANCE, + ], + logger, + }); }, ); diff --git a/apps/web/utils/auth.test.ts b/apps/web/utils/auth.test.ts index cbd8bdefd1..51ddcffe76 100644 --- a/apps/web/utils/auth.test.ts +++ b/apps/web/utils/auth.test.ts @@ -144,7 +144,9 @@ describe("saveTokens", () => { }), }), ); - expect(clearUserErrorMessages).toHaveBeenCalledWith({ userId: "user_1" }); + expect(clearUserErrorMessages).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user_1" }), + ); }); it("clears disconnectedAt and error messages when saving tokens via providerAccountId", async () => { @@ -174,6 +176,8 @@ describe("saveTokens", () => { }), }), ); - expect(clearUserErrorMessages).toHaveBeenCalledWith({ userId: "user_1" }); + expect(clearUserErrorMessages).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user_1" }), + ); }); }); diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index c7c3ab0ebe..751c22f975 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -418,7 +418,7 @@ async function handleLinkAccount(account: Account) { }), ]); - await clearUserErrorMessages({ userId: account.userId }); + await clearUserErrorMessages({ userId: account.userId, logger }); // Handle premium account seats await updateAccountSeats({ userId: account.userId }).catch((error) => { @@ -502,7 +502,7 @@ export async function saveTokens({ select: { userId: true }, }); - await clearUserErrorMessages({ userId: emailAccount.userId }); + await clearUserErrorMessages({ userId: emailAccount.userId, logger }); } else { if (!providerAccountId) { logger.error("No providerAccountId found in database", { @@ -524,7 +524,7 @@ export async function saveTokens({ data, }); - await clearUserErrorMessages({ userId: account.userId }); + await clearUserErrorMessages({ userId: account.userId, logger }); return account; } diff --git a/apps/web/utils/auth/cleanup-invalid-tokens.test.ts b/apps/web/utils/auth/cleanup-invalid-tokens.test.ts index ef8a1b8aa2..1873c23973 100644 --- a/apps/web/utils/auth/cleanup-invalid-tokens.test.ts +++ b/apps/web/utils/auth/cleanup-invalid-tokens.test.ts @@ -5,6 +5,8 @@ import { sendReconnectionEmail } from "@inboxzero/resend"; import { createScopedLogger } from "@/utils/logger"; import { addUserErrorMessage } from "@/utils/error-messages"; +const logger = createScopedLogger("test"); + vi.mock("@/utils/prisma"); vi.mock("@inboxzero/resend", () => ({ sendReconnectionEmail: vi.fn(), @@ -20,8 +22,6 @@ vi.mock("@/utils/unsubscribe", () => ({ })); describe("cleanupInvalidTokens", () => { - const logger = createScopedLogger("test"); - beforeEach(() => { vi.clearAllMocks(); }); @@ -58,6 +58,7 @@ describe("cleanupInvalidTokens", () => { "user_1", "Account disconnected", expect.stringContaining("test@example.com"), + logger, ); }); @@ -80,6 +81,7 @@ describe("cleanupInvalidTokens", () => { "user_1", "Account disconnected", expect.stringContaining("test@example.com"), + logger, ); }); diff --git a/apps/web/utils/auth/cleanup-invalid-tokens.ts b/apps/web/utils/auth/cleanup-invalid-tokens.ts index f14c739dd0..e6585d273d 100644 --- a/apps/web/utils/auth/cleanup-invalid-tokens.ts +++ b/apps/web/utils/auth/cleanup-invalid-tokens.ts @@ -102,6 +102,7 @@ export async function cleanupInvalidTokens({ emailAccount.userId, ErrorType.ACCOUNT_DISCONNECTED, `The connection for ${emailAccount.email} was disconnected. Please reconnect your account to resume automation.`, + logger, ); } diff --git a/apps/web/utils/error-messages/index.ts b/apps/web/utils/error-messages/index.ts index 4b373e1eea..a857b18d32 100644 --- a/apps/web/utils/error-messages/index.ts +++ b/apps/web/utils/error-messages/index.ts @@ -1,14 +1,16 @@ import prisma from "@/utils/prisma"; -import { createScopedLogger } from "@/utils/logger"; +import type { Logger } from "@/utils/logger"; import { captureException } from "@/utils/error"; - -const logger = createScopedLogger("error-messages"); +import { sendActionRequiredEmail } from "@inboxzero/resend"; +import { env } from "@/env"; +import { createUnsubscribeToken } from "@/utils/unsubscribe"; // Used to store error messages for a user which we display in the UI type ErrorMessageEntry = { message: string; timestamp: string; + emailSentAt?: string; }; type ErrorMessages = Record; @@ -27,10 +29,11 @@ export async function addUserErrorMessage( userId: string, errorType: (typeof ErrorType)[keyof typeof ErrorType], errorMessage: string, + logger: Logger, ): Promise { const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { - logger.warn("User not found", { userId }); + logger.warn("User not found"); return; } @@ -52,8 +55,10 @@ export async function addUserErrorMessage( export async function clearUserErrorMessages({ userId, + logger, }: { userId: string; + logger: Logger; }): Promise { try { await prisma.user.update({ @@ -61,11 +66,46 @@ export async function clearUserErrorMessages({ data: { errorMessages: {} }, }); } catch (error) { - logger.error("Error clearing user error messages:", { + logger.error("Error clearing user error messages:", { error }); + captureException(error, { extra: { userId } }); + } +} + +export async function clearSpecificErrorMessages({ + userId, + errorTypes, + logger, +}: { + userId: string; + errorTypes: (typeof ErrorType)[keyof typeof ErrorType][]; + logger: Logger; +}): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { errorMessages: true }, + }); + + if (!user) return; + + const currentErrorMessages = (user.errorMessages as ErrorMessages) || {}; + const updatedErrorMessages = { ...currentErrorMessages }; + + for (const errorType of errorTypes) { + delete updatedErrorMessages[errorType]; + } + + await prisma.user.update({ + where: { id: userId }, + data: { errorMessages: updatedErrorMessages }, + }); + } catch (error) { + logger.error("Error clearing specific error messages:", { userId, + errorTypes, error, }); - captureException(error, { extra: { userId } }); + captureException(error, { extra: { userId, errorTypes } }); } } @@ -77,3 +117,121 @@ export const ErrorType = { ANTHROPIC_INSUFFICIENT_BALANCE: "Anthropic insufficient balance", ACCOUNT_DISCONNECTED: "Account disconnected", }; + +const errorTypeConfig: Record< + (typeof ErrorType)[keyof typeof ErrorType], + { label: string; actionUrl: string; actionLabel: string } +> = { + [ErrorType.INCORRECT_OPENAI_API_KEY]: { + label: "API Key Issue", + actionUrl: "/settings", + actionLabel: "Update API Key", + }, + [ErrorType.INVALID_OPENAI_MODEL]: { + label: "Invalid AI Model", + actionUrl: "/settings", + actionLabel: "Update Settings", + }, + [ErrorType.OPENAI_API_KEY_DEACTIVATED]: { + label: "API Key Deactivated", + actionUrl: "/settings", + actionLabel: "Update API Key", + }, + [ErrorType.OPENAI_RETRY_ERROR]: { + label: "API Quota Exceeded", + actionUrl: "/settings", + actionLabel: "Update Settings", + }, + [ErrorType.ANTHROPIC_INSUFFICIENT_BALANCE]: { + label: "Insufficient Credits", + actionUrl: "/settings", + actionLabel: "Update Settings", + }, + [ErrorType.ACCOUNT_DISCONNECTED]: { + label: "Account Disconnected", + actionUrl: "/accounts", + actionLabel: "Reconnect Account", + }, +}; + +export async function addUserErrorMessageWithNotification({ + userId, + userEmail, + emailAccountId, + errorType, + errorMessage, + logger, +}: { + userId: string; + userEmail: string; + emailAccountId: string; + errorType: (typeof ErrorType)[keyof typeof ErrorType]; + errorMessage: string; + logger: Logger; +}): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { errorMessages: true }, + }); + + if (!user) { + logger.warn("User not found"); + return; + } + + const currentErrorMessages = (user.errorMessages as ErrorMessages) || {}; + const existingEntry = currentErrorMessages[errorType]; + const shouldSendEmail = !existingEntry?.emailSentAt; + + const newEntry: ErrorMessageEntry = { + message: errorMessage, + timestamp: new Date().toISOString(), + emailSentAt: existingEntry?.emailSentAt, + }; + + if (shouldSendEmail) { + try { + const config = errorTypeConfig[errorType]; + const unsubscribeToken = await createUnsubscribeToken({ + emailAccountId, + }); + + await sendActionRequiredEmail({ + from: env.RESEND_FROM_EMAIL, + to: userEmail, + emailProps: { + baseUrl: env.NEXT_PUBLIC_BASE_URL, + email: userEmail, + unsubscribeToken, + errorType: config.label, + errorMessage, + actionUrl: config.actionUrl, + actionLabel: config.actionLabel, + }, + }); + + newEntry.emailSentAt = new Date().toISOString(); + logger.info("Sent action required email", { errorType }); + } catch (emailError) { + logger.error("Failed to send action required email", { + error: emailError, + }); + // Continue to save the error message even if email fails + } + } + + const newErrorMessages = { + ...currentErrorMessages, + [errorType]: newEntry, + }; + + await prisma.user.update({ + where: { id: userId }, + data: { errorMessages: newErrorMessages }, + }); + } catch (error) { + logger.error("Error in addUserErrorMessageWithNotification", { error }); + captureException(error, { extra: { userId, errorType } }); + } +} diff --git a/apps/web/utils/llms/index.ts b/apps/web/utils/llms/index.ts index 06ea254c7b..c963266533 100644 --- a/apps/web/utils/llms/index.ts +++ b/apps/web/utils/llms/index.ts @@ -18,7 +18,10 @@ import { jsonrepair } from "jsonrepair"; import type { LanguageModelV2 } from "@ai-sdk/provider"; import { saveAiUsage } from "@/utils/usage"; import type { EmailAccountWithAI, UserAIFields } from "@/utils/llms/types"; -import { addUserErrorMessage, ErrorType } from "@/utils/error-messages"; +import { + addUserErrorMessageWithNotification, + ErrorType, +} from "@/utils/error-messages"; import { captureException, isAnthropicInsufficientBalanceError, @@ -313,45 +316,65 @@ async function handleError( modelName, }); + if (RetryError.isInstance(error) && isOpenAIRetryError(error)) { + return await addUserErrorMessageWithNotification({ + userId, + userEmail, + emailAccountId, + errorType: ErrorType.OPENAI_RETRY_ERROR, + errorMessage: + "You have exceeded your OpenAI API quota. Please check your OpenAI account.", + logger, + }); + } + if (APICallError.isInstance(error)) { if (isIncorrectOpenAIAPIKeyError(error)) { - return await addUserErrorMessage( + return await addUserErrorMessageWithNotification({ userId, - ErrorType.INCORRECT_OPENAI_API_KEY, - error.message, - ); + userEmail, + emailAccountId, + errorType: ErrorType.INCORRECT_OPENAI_API_KEY, + errorMessage: + "Your OpenAI API key is invalid. Please update it in your settings.", + logger, + }); } if (isInvalidOpenAIModelError(error)) { - return await addUserErrorMessage( + return await addUserErrorMessageWithNotification({ userId, - ErrorType.INVALID_OPENAI_MODEL, - error.message, - ); + userEmail, + emailAccountId, + errorType: ErrorType.INVALID_OPENAI_MODEL, + errorMessage: + "The AI model you specified does not exist. Please check your settings.", + logger, + }); } if (isOpenAIAPIKeyDeactivatedError(error)) { - return await addUserErrorMessage( + return await addUserErrorMessageWithNotification({ userId, - ErrorType.OPENAI_API_KEY_DEACTIVATED, - error.message, - ); - } - - if (RetryError.isInstance(error) && isOpenAIRetryError(error)) { - return await addUserErrorMessage( - userId, - ErrorType.OPENAI_RETRY_ERROR, - error.message, - ); + userEmail, + emailAccountId, + errorType: ErrorType.OPENAI_API_KEY_DEACTIVATED, + errorMessage: + "Your OpenAI API key has been deactivated. Please update it in your settings.", + logger, + }); } if (isAnthropicInsufficientBalanceError(error)) { - return await addUserErrorMessage( + return await addUserErrorMessageWithNotification({ userId, - ErrorType.ANTHROPIC_INSUFFICIENT_BALANCE, - error.message, - ); + userEmail, + emailAccountId, + errorType: ErrorType.ANTHROPIC_INSUFFICIENT_BALANCE, + errorMessage: + "Your Anthropic account has insufficient credits. Please add credits or update your settings.", + logger, + }); } } } diff --git a/apps/web/utils/meeting-briefs/recipient-context.test.ts b/apps/web/utils/meeting-briefs/recipient-context.test.ts index 5c8a688060..cedf7d231d 100644 --- a/apps/web/utils/meeting-briefs/recipient-context.test.ts +++ b/apps/web/utils/meeting-briefs/recipient-context.test.ts @@ -7,7 +7,9 @@ import { import type { CalendarEventProvider } from "@/utils/calendar/event-types"; import type { CalendarEvent } from "@/utils/calendar/event-types"; import { createCalendarEventProviders } from "@/utils/calendar/event-provider"; -import type { Logger } from "@/utils/logger"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("test"); vi.mock("@/utils/calendar/event-provider"); vi.mock("@/utils/date", () => ({ @@ -50,15 +52,6 @@ vi.mock("@/utils/date", () => ({ })); describe("recipient-context", () => { - const mockLogger: Logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - trace: vi.fn(), - with: vi.fn(() => mockLogger), - flush: vi.fn(() => Promise.resolve()), - }; - beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); @@ -245,7 +238,7 @@ Use this context naturally if relevant. For past meetings, you might reference t const result = await getMeetingContext({ emailAccountId: "test-account-id", recipientEmail: "recipient@example.com", - logger: mockLogger, + logger, }); expect(result).toEqual([]); @@ -298,7 +291,7 @@ Use this context naturally if relevant. For past meetings, you might reference t const result = await getMeetingContext({ emailAccountId: "test-account-id", recipientEmail: "recipient@example.com", - logger: mockLogger, + logger, }); expect(result).toHaveLength(2); @@ -352,7 +345,7 @@ Use this context naturally if relevant. For past meetings, you might reference t emailAccountId: "test-account-id", recipientEmail: "recipient@example.com", additionalRecipients: ["cc@example.com"], - logger: mockLogger, + logger, }); expect(result).toHaveLength(1); @@ -372,11 +365,10 @@ Use this context naturally if relevant. For past meetings, you might reference t const result = await getMeetingContext({ emailAccountId: "test-account-id", recipientEmail: "recipient@example.com", - logger: mockLogger, + logger, }); expect(result).toEqual([]); - expect(mockLogger.warn).toHaveBeenCalled(); }); it("sorts past meetings by most recent first", async () => { @@ -413,7 +405,7 @@ Use this context naturally if relevant. For past meetings, you might reference t const result = await getMeetingContext({ emailAccountId: "test-account-id", recipientEmail: "recipient@example.com", - logger: mockLogger, + logger, }); expect(result).toHaveLength(2); @@ -455,7 +447,7 @@ Use this context naturally if relevant. For past meetings, you might reference t const result = await getMeetingContext({ emailAccountId: "test-account-id", recipientEmail: "recipient@example.com", - logger: mockLogger, + logger, }); expect(result).toHaveLength(2); @@ -492,7 +484,7 @@ Use this context naturally if relevant. For past meetings, you might reference t const result = await getMeetingContext({ emailAccountId: "test-account-id", recipientEmail: "recipient@example.com", - logger: mockLogger, + logger, }); // Should be limited to MAX_MEETINGS_PER_CATEGORY (5) per category diff --git a/apps/web/utils/outlook/client.ts b/apps/web/utils/outlook/client.ts index 157ecce3bf..54bb48b1fc 100644 --- a/apps/web/utils/outlook/client.ts +++ b/apps/web/utils/outlook/client.ts @@ -1,6 +1,7 @@ import { Client } from "@microsoft/microsoft-graph-client"; import type { User } from "@microsoft/microsoft-graph-types"; import { saveTokens } from "@/utils/auth"; +import { cleanupInvalidTokens } from "@/utils/auth/cleanup-invalid-tokens"; import { env } from "@/env"; import type { Logger } from "@/utils/logger"; import { SCOPES } from "@/utils/outlook/scopes"; @@ -186,6 +187,13 @@ export const getOutlookClientWithRefresh = async ({ errorMessage, }, ); + + await cleanupInvalidTokens({ + emailAccountId, + reason: "invalid_grant", + logger, + }); + throw new SafeError( "Your Microsoft authorization has expired. Please sign out and log in again to reconnect your account.", ); diff --git a/packages/resend/emails/action-required.tsx b/packages/resend/emails/action-required.tsx new file mode 100644 index 0000000000..25db9a2fe8 --- /dev/null +++ b/packages/resend/emails/action-required.tsx @@ -0,0 +1,161 @@ +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Img, + Link, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import type { FC } from "react"; + +export type ActionRequiredEmailProps = { + baseUrl: string; + email: string; + unsubscribeToken: string; + errorType: string; + errorMessage: string; + actionUrl: string; + actionLabel: string; +}; + +type ActionRequiredEmailComponent = FC & { + PreviewProps: ActionRequiredEmailProps; +}; + +const ActionRequiredEmail: ActionRequiredEmailComponent = ({ + baseUrl = "https://www.getinboxzero.com", + email, + unsubscribeToken, + errorType, + errorMessage, + actionUrl, + actionLabel, +}: ActionRequiredEmailProps) => { + const fullActionUrl = actionUrl.startsWith("http") + ? actionUrl + : `${baseUrl}${actionUrl}`; + + return ( + + + + + + {/* Header */} +
+ + Inbox Zero + + + + + Inbox Zero + + + + + Action Required: {errorType} + +
+ + {/* Main Content */} +
+ Hi, + + + We encountered an issue with your Inbox Zero account ( + {email}): + + + + {errorMessage} + + + + Your automated email rules and AI assistant features are paused + until this is resolved. + + + {/* CTA Button */} +
+ +
+ + + If you need help, please visit our support page or reply to this + email. + +
+ + {/* Footer */} +
+