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
4 changes: 2 additions & 2 deletions apps/web/utils/actions/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
17 changes: 16 additions & 1 deletion apps/web/utils/actions/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand All @@ -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({
Expand All @@ -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,
});
},
);

Expand Down
8 changes: 6 additions & 2 deletions apps/web/utils/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -174,6 +176,8 @@ describe("saveTokens", () => {
}),
}),
);
expect(clearUserErrorMessages).toHaveBeenCalledWith({ userId: "user_1" });
expect(clearUserErrorMessages).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user_1" }),
);
});
});
6 changes: 3 additions & 3 deletions apps/web/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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", {
Expand All @@ -524,7 +524,7 @@ export async function saveTokens({
data,
});

await clearUserErrorMessages({ userId: account.userId });
await clearUserErrorMessages({ userId: account.userId, logger });

return account;
}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/utils/auth/cleanup-invalid-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -20,8 +22,6 @@ vi.mock("@/utils/unsubscribe", () => ({
}));

describe("cleanupInvalidTokens", () => {
const logger = createScopedLogger("test");

beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -58,6 +58,7 @@ describe("cleanupInvalidTokens", () => {
"user_1",
"Account disconnected",
expect.stringContaining("test@example.com"),
logger,
);
});

Expand All @@ -80,6 +81,7 @@ describe("cleanupInvalidTokens", () => {
"user_1",
"Account disconnected",
expect.stringContaining("test@example.com"),
logger,
);
});

Expand Down
1 change: 1 addition & 0 deletions apps/web/utils/auth/cleanup-invalid-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down
170 changes: 164 additions & 6 deletions apps/web/utils/error-messages/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, ErrorMessageEntry>;
Expand All @@ -27,10 +29,11 @@ export async function addUserErrorMessage(
userId: string,
errorType: (typeof ErrorType)[keyof typeof ErrorType],
errorMessage: string,
logger: Logger,
): Promise<void> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
logger.warn("User not found", { userId });
logger.warn("User not found");
return;
}

Expand All @@ -52,20 +55,57 @@ export async function addUserErrorMessage(

export async function clearUserErrorMessages({
userId,
logger,
}: {
userId: string;
logger: Logger;
}): Promise<void> {
try {
await prisma.user.update({
where: { id: userId },
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<void> {
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 } });
}
}

Expand All @@ -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<void> {
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,
});
Comment thread
elie222 marked this conversation as resolved.

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 });
Comment on lines +193 to +215
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.

When Resend is not configured (sendEmail returns early because resend is null) the try block in addUserErrorMessageWithNotification still reaches line 214 and sets emailSentAt, so the code never retries notifications even though nothing was delivered. Because sendEmail doesn't throw when the API key is missing, we silently skip notifying the user—there is no log/error via the injected logger and no follow-up email. Please either surface the missing Resend configuration or avoid marking emailSentAt until sendActionRequiredEmail actually sends an email so that the failure is observable and the notification is retried.


Finding type: Logical Bugs

} catch (emailError) {
logger.error("Failed to send action required email", {
error: emailError,
});
// Continue to save the error message even if email fails
}
}
Comment thread
elie222 marked this conversation as resolved.

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 } });
}
}
Loading
Loading