diff --git a/apps/web/app/api/outlook/linking/callback/route.ts b/apps/web/app/api/outlook/linking/callback/route.ts index a915bb1995..34ab274aac 100644 --- a/apps/web/app/api/outlook/linking/callback/route.ts +++ b/apps/web/app/api/outlook/linking/callback/route.ts @@ -4,7 +4,7 @@ import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; import { OUTLOOK_LINKING_STATE_COOKIE_NAME } from "@/utils/outlook/constants"; import { withError } from "@/utils/middleware"; -import { SafeError } from "@/utils/error"; +import { captureException, SafeError } from "@/utils/error"; import { transferPremiumDuringMerge } from "@/utils/user/merge-premium"; import { parseOAuthState } from "@/utils/oauth/state"; @@ -76,6 +76,14 @@ export const GET = withError(async (request) => { const tokens = await tokenResponse.json(); if (!tokenResponse.ok) { + logger.error("Failed to exchange code for tokens", { + error: tokens.error_description, + }); + captureException( + new Error( + tokens.error_description || "Failed to exchange code for tokens", + ), + ); throw new Error( tokens.error_description || "Failed to exchange code for tokens", ); diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 423022d7b6..91349ef87a 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -3,7 +3,6 @@ import { sso } from "@better-auth/sso"; import { createContact as createLoopsContact } from "@inboxzero/loops"; import { createContact as createResendContact } from "@inboxzero/resend"; -import type { Prisma } from "@prisma/client"; import type { Account, AuthContext, User } from "better-auth"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; @@ -375,24 +374,21 @@ async function handleLinkAccount(account: Account) { return; } - // --- Create/Update the corresponding EmailAccount record --- - const emailAccountData: Prisma.EmailAccountUpsertArgs = { + const data = { + userId: account.userId, + accountId: account.id, + name: primaryName, + image: primaryPhotoUrl, + }; + + await prisma.emailAccount.upsert({ where: { email: profileData?.email }, - update: { - userId: account.userId, - accountId: account.id, - name: primaryName, - image: primaryPhotoUrl, - }, + update: data, create: { + ...data, email: primaryEmail, - userId: account.userId, - accountId: account.id, - name: primaryName, - image: primaryPhotoUrl, }, - }; - await prisma.emailAccount.upsert(emailAccountData); + }); // Handle premium account seats await updateAccountSeats({ userId: account.userId }).catch((error) => { diff --git a/apps/web/utils/email/watch-manager.ts b/apps/web/utils/email/watch-manager.ts index ff5cda4690..e1481b03da 100644 --- a/apps/web/utils/email/watch-manager.ts +++ b/apps/web/utils/email/watch-manager.ts @@ -93,6 +93,7 @@ async function watchEmailAccounts( "invalid_grant", "Mail service not enabled", "Insufficient Permission", + "AADSTS7000215", // Raw Azure AD error for invalid client secret (old tokens after secret rotation) ]; if (warn.some((w) => error.message.includes(w))) { diff --git a/apps/web/utils/logger.ts b/apps/web/utils/logger.ts index bd87dd118d..25f10158aa 100644 --- a/apps/web/utils/logger.ts +++ b/apps/web/utils/logger.ts @@ -185,13 +185,7 @@ function processErrorsInObject(obj: unknown): unknown { } // Field names that contain PII and should be hashed in production -const SENSITIVE_FIELD_NAMES = new Set([ - "email", - "from", - "sender", - "to", - "userEmail", -]); +const SENSITIVE_FIELD_NAMES = new Set(["from", "sender", "to"]); // Field names that should NEVER be logged - replaced with boolean const REDACTED_FIELD_NAMES = new Set([ diff --git a/apps/web/utils/outlook/client.ts b/apps/web/utils/outlook/client.ts index ac14c79808..2ddf8de3cf 100644 --- a/apps/web/utils/outlook/client.ts +++ b/apps/web/utils/outlook/client.ts @@ -138,7 +138,23 @@ export const getOutlookClientWithRefresh = async ({ const tokens = await response.json(); if (!response.ok) { - throw new Error(tokens.error_description || "Failed to refresh token"); + const errorMessage = + tokens.error_description || "Failed to refresh token"; + + // AADSTS7000215 = Invalid client secret + // Happens when Azure AD client secret rotates or refresh token expires + // Background processes (watch-manager) will catch and log this as a warning + // User-facing flows will show an error prompting reconnection + if (errorMessage.includes("AADSTS7000215")) { + logger.warn( + "Microsoft refresh token failed - user may need to reconnect", + { + emailAccountId, + }, + ); + } + + throw new Error(errorMessage); } // Save new tokens diff --git a/version.txt b/version.txt index 9bc56b7da7..542963996e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.18.11 +v2.18.13