diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f7e21f421..abbdf61aca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,6 @@ jobs: GOOGLE_CLIENT_ID: "client_id" GOOGLE_CLIENT_SECRET: "client_secret" GOOGLE_PUBSUB_TOPIC_NAME: "topic" - GOOGLE_ENCRYPT_SECRET: "secret" - GOOGLE_ENCRYPT_SALT: "salt" + EMAIL_ENCRYPT_SECRET: "secret" + EMAIL_ENCRYPT_SALT: "salt" INTERNAL_API_KEY: "secret" diff --git a/README.md b/README.md index 69d625ec4e..bebbe994f3 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ There are two parts to Inbox Zero: If you're looking to contribute to the project, the email client is the best place to do this. -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Felie222%2Finbox-zero&env=NEXTAUTH_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_ENCRYPT_SECRET,GOOGLE_ENCRYPT_SALT,UPSTASH_REDIS_URL,UPSTASH_REDIS_TOKEN,GOOGLE_PUBSUB_TOPIC_NAME,DATABASE_URL) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Felie222%2Finbox-zero&env=NEXTAUTH_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,EMAIL_ENCRYPT_SECRET,EMAIL_ENCRYPT_SALT,UPSTASH_REDIS_URL,UPSTASH_REDIS_TOKEN,GOOGLE_PUBSUB_TOPIC_NAME,DATABASE_URL) Thanks to Vercel for sponsoring Inbox Zero in support of open-source software. @@ -106,8 +106,8 @@ The required environment variables: Secrets: - `NEXTAUTH_SECRET` -- can be any random string (try using `openssl rand -hex 32` for a quick secure random string) -- `GOOGLE_ENCRYPT_SECRET` -- Secret key for encrypting OAuth tokens (try using `openssl rand -hex 32` for a secure key) -- `GOOGLE_ENCRYPT_SALT` -- Salt for encrypting OAuth tokens (try using `openssl rand -hex 16` for a secure salt) +- `EMAIL_ENCRYPT_SECRET` -- Secret key for encrypting OAuth tokens (try using `openssl rand -hex 32` for a secure key) +- `EMAIL_ENCRYPT_SALT` -- Salt for encrypting OAuth tokens (try using `openssl rand -hex 16` for a secure salt) Redis: @@ -320,7 +320,7 @@ ngrok http --domain=XYZ.ngrok-free.app 3000 And then update the webhook endpoint in the [Google PubSub subscriptions dashboard](https://console.cloud.google.com/cloudpubsub/subscription/list). -To start watching emails visit: `/api/google/watch/all` +To start watching emails visit: `/api/watch/all` ### Watching for email updates @@ -330,7 +330,7 @@ The Google watch is necessary. Others are optional. ```json "crons": [ { - "path": "/api/google/watch/all", + "path": "/api/watch/all", "schedule": "0 1 * * *" }, { diff --git a/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx index f695cb4767..8de2799d04 100644 --- a/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx +++ b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx @@ -10,7 +10,7 @@ const permissionsChecked: Record = {}; export function PermissionsCheck() { const router = useRouter(); - const { emailAccountId, provider } = useAccount(); + const { emailAccountId } = useAccount(); useEffect(() => { if (permissionsChecked[emailAccountId]) return; @@ -22,7 +22,7 @@ export function PermissionsCheck() { if (result?.data?.hasRefreshToken === false) router.replace(prefixPath(emailAccountId, "/permissions/consent")); }); - }, [router, emailAccountId, provider]); + }, [router, emailAccountId]); return null; } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx index c8cd08893e..46c85964a0 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx @@ -5,7 +5,7 @@ import { decodeSnippet } from "@/utils/gmail/decode"; import { ActionBadgeExpanded } from "@/components/PlanBadge"; import { Tooltip } from "@/components/Tooltip"; import { EmailDate } from "@/components/email-list/EmailDate"; -import { getGmailUrl } from "@/utils/url"; +import { getEmailUrlForMessage } from "@/utils/url"; import { HoverCard } from "@/components/HoverCard"; import { Badge } from "@/components/Badge"; import { Button } from "@/components/ui/button"; @@ -17,6 +17,7 @@ import { ExecutedRuleStatus } from "@prisma/client"; import { FixWithChat } from "@/app/(app)/[emailAccountId]/assistant/FixWithChat"; import type { SetInputFunction } from "@/components/assistant-chat/types"; import { useAssistantNavigation } from "@/hooks/useAssistantNavigation"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function EmailCell({ from, @@ -43,7 +44,11 @@ export function EmailCell({
{subject} - + diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx index 97bd4a3366..daea555ad4 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx @@ -50,13 +50,11 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { parseAsBoolean.withDefault(false), ); - const { provider } = useAccount(); - const { data, isLoading, isValidating, error, setSize, mutate, size } = useSWRInfinite( - (_index, previousPageData) => { + (index, previousPageData) => { // Always return the URL for the first page - if (_index === 0) { + if (index === 0) { const params = new URLSearchParams(); if (searchQuery) params.set("q", searchQuery); const paramsString = params.toString(); diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts index 8ee09bd331..170350f7f6 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts @@ -15,20 +15,17 @@ import type { Row } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import type { GetThreadsResponse } from "@/app/api/threads/basic/route"; import { isDefined } from "@/utils/types"; import { fetchWithAccount } from "@/utils/fetch"; -import { useAccount } from "@/providers/EmailAccountProvider"; async function unsubscribeAndArchive({ newsletterEmail, mutate, refetchPremium, emailAccountId, - provider, }: { newsletterEmail: string; mutate: () => Promise; refetchPremium: () => Promise; emailAccountId: string; - provider: string; }) { await setNewsletterStatusAction(emailAccountId, { newsletterEmail, @@ -40,7 +37,6 @@ async function unsubscribeAndArchive({ await addToArchiveSenderQueue({ sender: newsletterEmail, emailAccountId, - provider, }); } @@ -60,7 +56,6 @@ export function useUnsubscribe({ refetchPremium: () => Promise; }) { const [unsubscribeLoading, setUnsubscribeLoading] = React.useState(false); - const { provider } = useAccount(); const onUnsubscribe = useCallback(async () => { if (!hasUnsubscribeAccess) return; @@ -82,7 +77,6 @@ export function useUnsubscribe({ mutate, refetchPremium, emailAccountId, - provider, }); } } catch (error) { @@ -99,7 +93,6 @@ export function useUnsubscribe({ refetchPremium, posthog, emailAccountId, - provider, ]); return { @@ -127,7 +120,6 @@ export function useBulkUnsubscribe({ }) { const [bulkUnsubscribeLoading, setBulkUnsubscribeLoading] = React.useState(false); - const { provider } = useAccount(); const onBulkUnsubscribe = useCallback( async (items: T[]) => { @@ -145,7 +137,6 @@ export function useBulkUnsubscribe({ mutate, refetchPremium, emailAccountId, - provider, }); } catch (error) { captureException(error); @@ -159,14 +150,7 @@ export function useBulkUnsubscribe({ setBulkUnsubscribeLoading(false); }, - [ - hasUnsubscribeAccess, - mutate, - posthog, - refetchPremium, - emailAccountId, - provider, - ], + [hasUnsubscribeAccess, mutate, posthog, refetchPremium, emailAccountId], ); return { @@ -182,7 +166,6 @@ async function autoArchive({ mutate, refetchPremium, emailAccountId, - provider, }: { name: string; labelId: string | undefined; @@ -190,7 +173,6 @@ async function autoArchive({ mutate: () => Promise; refetchPremium: () => Promise; emailAccountId: string; - provider: string; }) { await onAutoArchive({ emailAccountId, @@ -209,7 +191,6 @@ async function autoArchive({ sender: name, labelId, emailAccountId, - provider, }); } @@ -229,7 +210,6 @@ export function useAutoArchive({ emailAccountId: string; }) { const [autoArchiveLoading, setAutoArchiveLoading] = React.useState(false); - const { provider } = useAccount(); const onAutoArchiveClick = useCallback(async () => { if (!hasUnsubscribeAccess) return; @@ -243,7 +223,6 @@ export function useAutoArchive({ mutate, refetchPremium, emailAccountId, - provider, }); posthog.capture("Clicked Auto Archive"); @@ -256,7 +235,6 @@ export function useAutoArchive({ hasUnsubscribeAccess, posthog, emailAccountId, - provider, ]); const onDisableAutoArchive = useCallback(async () => { @@ -290,19 +268,11 @@ export function useAutoArchive({ mutate, refetchPremium, emailAccountId, - provider, }); setAutoArchiveLoading(false); }, - [ - item.name, - mutate, - refetchPremium, - hasUnsubscribeAccess, - emailAccountId, - provider, - ], + [item.name, mutate, refetchPremium, hasUnsubscribeAccess, emailAccountId], ); return { @@ -326,7 +296,6 @@ export function useBulkAutoArchive({ }) { const [bulkAutoArchiveLoading, setBulkAutoArchiveLoading] = React.useState(false); - const { provider } = useAccount(); const onBulkAutoArchive = useCallback( async (items: T[]) => { @@ -342,13 +311,12 @@ export function useBulkAutoArchive({ mutate, refetchPremium, emailAccountId, - provider, }); } setBulkAutoArchiveLoading(false); }, - [hasUnsubscribeAccess, mutate, refetchPremium, emailAccountId, provider], + [hasUnsubscribeAccess, mutate, refetchPremium, emailAccountId], ); return { @@ -436,12 +404,10 @@ async function archiveAll({ name, onFinish, emailAccountId, - provider, }: { name: string; onFinish: () => void; emailAccountId: string; - provider: string; }) { toast.promise( async () => { @@ -449,7 +415,6 @@ async function archiveAll({ addToArchiveSenderQueue({ sender: name, emailAccountId, - provider, onSuccess: (totalThreads) => { onFinish(); resolve(totalThreads); @@ -481,7 +446,6 @@ export function useArchiveAll({ emailAccountId: string; }) { const [archiveAllLoading, setArchiveAllLoading] = React.useState(false); - const { provider } = useAccount(); const onArchiveAll = async () => { setArchiveAllLoading(true); @@ -492,7 +456,6 @@ export function useArchiveAll({ name: item.name, onFinish: () => setArchiveAllLoading(false), emailAccountId, - provider, }); setArchiveAllLoading(false); @@ -513,8 +476,6 @@ export function useBulkArchive({ posthog: PostHog; emailAccountId: string; }) { - const { provider } = useAccount(); - const onBulkArchive = async (items: T[]) => { posthog.capture("Clicked Bulk Archive"); @@ -523,7 +484,6 @@ export function useBulkArchive({ name: item.name, onFinish: mutate, emailAccountId, - provider, }); } }; @@ -535,12 +495,10 @@ async function deleteAllFromSender({ name, onFinish, emailAccountId, - provider, }: { name: string; onFinish: () => void; emailAccountId: string; - provider: string; }) { toast.promise( async () => { @@ -555,7 +513,7 @@ async function deleteAllFromSender({ if (data?.threads?.length) { await new Promise((resolve, reject) => { deleteEmails({ - threadIds: data.threads.map((t: any) => t.id).filter(isDefined), + threadIds: data.threads.map((t) => t.id).filter(isDefined), onSuccess: () => { onFinish(); resolve(); @@ -589,7 +547,6 @@ export function useDeleteAllFromSender({ emailAccountId: string; }) { const [deleteAllLoading, setDeleteAllLoading] = React.useState(false); - const { provider } = useAccount(); const onDeleteAll = async () => { setDeleteAllLoading(true); @@ -600,7 +557,6 @@ export function useDeleteAllFromSender({ name: item.name, onFinish: () => setDeleteAllLoading(false), emailAccountId, - provider, }); }; @@ -619,8 +575,6 @@ export function useBulkDelete({ posthog: PostHog; emailAccountId: string; }) { - const { provider } = useAccount(); - const onBulkDelete = async (items: T[]) => { posthog.capture("Clicked Bulk Delete"); @@ -629,7 +583,6 @@ export function useBulkDelete({ name: item.name, onFinish: () => mutate(), emailAccountId, - provider, }); } }; diff --git a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx index d518d5ee08..23c3cdb7db 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx @@ -9,6 +9,7 @@ import { CleanStep } from "@/app/(app)/[emailAccountId]/clean/types"; import { CleanAction } from "@prisma/client"; import { createEmailProvider } from "@/utils/email/provider"; import { checkUserOwnsEmailAccount } from "@/utils/email-account"; +import prisma from "@/utils/prisma"; export default async function CleanPage(props: { params: Promise<{ emailAccountId: string }>; @@ -27,9 +28,16 @@ export default async function CleanPage(props: { const { emailAccountId } = await props.params; await checkUserOwnsEmailAccount({ emailAccountId }); + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + account: { select: { provider: true } }, + }, + }); + const emailProvider = await createEmailProvider({ emailAccountId, - provider: "google", + provider: emailAccount?.account.provider ?? null, }); const { unhandledCount } = await getUnhandledCount(emailProvider); diff --git a/apps/web/app/(landing)/login/LoginForm.tsx b/apps/web/app/(landing)/login/LoginForm.tsx index 3378792b1e..e3c72d75f2 100644 --- a/apps/web/app/(landing)/login/LoginForm.tsx +++ b/apps/web/app/(landing)/login/LoginForm.tsx @@ -76,57 +76,33 @@ export function LoginForm() { - - - - - - - Sign in with Microsoft - - - Inbox Zero{"'"}s use and transfer of information received from - Microsoft APIs to any other app will adhere to{" "} - - Microsoft{"'"}s API terms of service and data privacy - requirements. - - -
- -
-
-
+
); } diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index 9bdd00dd61..01fe846cad 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -6,7 +6,8 @@ import { GmailLabel } from "@/utils/gmail/label"; import { hasAiAccess, isPremium } from "@/utils/premium"; import { ColdEmailSetting } from "@prisma/client"; import { captureException } from "@/utils/error"; -import { unwatchEmails } from "@/app/api/google/watch/controller"; +import { unwatchEmails } from "@/app/api/watch/controller"; +import { createEmailProvider } from "@/utils/email/provider"; import type { ProcessHistoryOptions } from "@/app/api/google/webhook/types"; import { processHistoryItem } from "@/app/api/google/webhook/process-history-item"; import { logger } from "@/app/api/google/webhook/logger"; @@ -37,8 +38,10 @@ export async function processHistoryForUser( coldEmailPrompt: true, coldEmailDigest: true, autoCategorizeSenders: true, + watchEmailsSubscriptionId: true, account: { select: { + provider: true, access_token: true, refresh_token: true, expires_at: true, @@ -85,11 +88,14 @@ export async function processHistoryForUser( stripeSubscriptionStatus: emailAccount.user.premium?.stripeSubscriptionStatus, }); + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: emailAccount.account?.provider || "google", + }); await unwatchEmails({ emailAccountId: emailAccount.id, - accessToken: emailAccount.account?.access_token, - refreshToken: emailAccount.account?.refresh_token, - expiresAt: emailAccount.account?.expires_at, + provider, + subscriptionId: emailAccount.watchEmailsSubscriptionId, }); return NextResponse.json({ ok: true }); } @@ -101,11 +107,14 @@ export async function processHistoryForUser( email, emailAccountId: emailAccount.id, }); + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: emailAccount.account?.provider || "google", + }); await unwatchEmails({ emailAccountId: emailAccount.id, - accessToken: emailAccount.account?.access_token, - refreshToken: emailAccount.account?.refresh_token, - expiresAt: emailAccount.account?.expires_at, + provider, + subscriptionId: emailAccount.watchEmailsSubscriptionId, }); return NextResponse.json({ ok: true }); } diff --git a/apps/web/app/api/labels/create/route.ts b/apps/web/app/api/labels/create/route.ts index e59528397c..c4b9c4bfdf 100644 --- a/apps/web/app/api/labels/create/route.ts +++ b/apps/web/app/api/labels/create/route.ts @@ -1,7 +1,5 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; -import prisma from "@/utils/prisma"; -import { createEmailProvider } from "@/utils/email/provider"; +import { withEmailProvider } from "@/utils/middleware"; import { z } from "zod"; const createLabelBody = z.object({ @@ -9,32 +7,11 @@ const createLabelBody = z.object({ description: z.string().nullish(), }); -export const POST = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const POST = withEmailProvider(async (request) => { + const { emailProvider } = request; const body = await request.json(); const { name, description } = createLabelBody.parse(body); - // Get the provider from the related account - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { - provider: true, - }, - }, - }, - }); - - if (!emailAccount) { - return NextResponse.json( - { error: "Email account not found" }, - { status: 404 }, - ); - } - - const provider = emailAccount.account.provider; - const emailProvider = await createEmailProvider({ emailAccountId, provider }); const label = await emailProvider.createLabel( name, description ? description : undefined, diff --git a/apps/web/app/api/labels/route.ts b/apps/web/app/api/labels/route.ts index 7b8c1646be..1f7f008044 100644 --- a/apps/web/app/api/labels/route.ts +++ b/apps/web/app/api/labels/route.ts @@ -1,7 +1,5 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; -import prisma from "@/utils/prisma"; -import { createEmailProvider } from "@/utils/email/provider"; +import { withEmailProvider } from "@/utils/middleware"; export type UnifiedLabel = { id: string; @@ -22,30 +20,9 @@ export type LabelsResponse = { export const dynamic = "force-dynamic"; export const maxDuration = 30; -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; - // Get the provider from the related account - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { - provider: true, - }, - }, - }, - }); - - if (!emailAccount) { - return NextResponse.json( - { error: "Email account not found" }, - { status: 404 }, - ); - } - - const provider = emailAccount.account.provider; - const emailProvider = await createEmailProvider({ emailAccountId, provider }); const labels = await emailProvider.getLabels(); // Map to unified format diff --git a/apps/web/app/api/messages/attachment/route.ts b/apps/web/app/api/messages/attachment/route.ts index 81ff9064d0..241892cb41 100644 --- a/apps/web/app/api/messages/attachment/route.ts +++ b/apps/web/app/api/messages/attachment/route.ts @@ -1,11 +1,9 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; +import { withEmailProvider } from "@/utils/middleware"; import { attachmentQuery } from "@/app/api/messages/validation"; -import { createEmailProvider } from "@/utils/email/provider"; -import prisma from "@/utils/prisma"; -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; const { searchParams } = new URL(request.url); @@ -16,28 +14,6 @@ export const GET = withEmailAccount(async (request) => { filename: searchParams.get("filename"), }); - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { provider: true }, - }, - }, - }); - - if (!emailAccount) { - return NextResponse.json( - { error: "Email account not found" }, - { status: 404 }, - ); - } - - const emailProvider = await createEmailProvider({ - emailAccountId, - provider: emailAccount.account.provider, - }); - const attachmentData = await emailProvider.getAttachment( query.messageId, query.attachmentId, diff --git a/apps/web/app/api/messages/batch/route.ts b/apps/web/app/api/messages/batch/route.ts index 5aa658a481..eccdd8aaad 100644 --- a/apps/web/app/api/messages/batch/route.ts +++ b/apps/web/app/api/messages/batch/route.ts @@ -1,9 +1,8 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; +import { withEmailProvider } from "@/utils/middleware"; import { messagesBatchQuery } from "@/app/api/messages/validation"; import { parseReply } from "@/utils/mail"; -import { createEmailProvider } from "@/utils/email/provider"; -import prisma from "@/utils/prisma"; +import { EmailProvider } from "@/utils/email/provider"; export type MessagesBatchResponse = { messages: Awaited>; @@ -11,32 +10,13 @@ export type MessagesBatchResponse = { async function getMessagesBatch({ messageIds, - emailAccountId, + emailProvider, parseReplies, }: { messageIds: string[]; - emailAccountId: string; + emailProvider: EmailProvider; parseReplies?: boolean; }) { - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { provider: true }, - }, - }, - }); - - if (!emailAccount) { - throw new Error("Email account not found"); - } - - const emailProvider = await createEmailProvider({ - emailAccountId, - provider: emailAccount.account.provider, - }); - const messages = await emailProvider.getMessagesBatch(messageIds); if (parseReplies) { @@ -50,8 +30,8 @@ async function getMessagesBatch({ return messages; } -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; const { searchParams } = new URL(request.url); const ids = searchParams.get("ids"); @@ -63,7 +43,7 @@ export const GET = withEmailAccount(async (request) => { const messages = await getMessagesBatch({ messageIds: query.ids, - emailAccountId, + emailProvider, parseReplies: query.parseReplies, }); diff --git a/apps/web/app/api/messages/route.ts b/apps/web/app/api/messages/route.ts index 4bf7b2e468..e90ffbacdb 100644 --- a/apps/web/app/api/messages/route.ts +++ b/apps/web/app/api/messages/route.ts @@ -1,11 +1,10 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; +import { withEmailProvider } from "@/utils/middleware"; import { messageQuerySchema } from "@/app/api/messages/validation"; import { createScopedLogger } from "@/utils/logger"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; import { GmailLabel } from "@/utils/gmail/label"; -import { createEmailProvider } from "@/utils/email/provider"; -import prisma from "@/utils/prisma"; +import { EmailProvider } from "@/utils/email/provider"; const logger = createScopedLogger("api/messages"); @@ -16,32 +15,15 @@ async function getMessages({ pageToken, emailAccountId, userEmail, + emailProvider, }: { query?: string | null; pageToken?: string | null; emailAccountId: string; userEmail: string; + emailProvider: EmailProvider; }) { try { - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { provider: true }, - }, - }, - }); - - if (!emailAccount) { - throw new Error("Email account not found"); - } - - const emailProvider = await createEmailProvider({ - emailAccountId, - provider: emailAccount.account.provider, - }); - const { messages, nextPageToken } = await emailProvider.getMessagesWithPagination({ query: query?.trim(), @@ -69,7 +51,7 @@ async function getMessages({ } // Provider-specific filtering - if (emailAccount.account.provider === "google") { + if (emailProvider.name === "google") { const isSent = message.labelIds?.includes(GmailLabel.SENT); const isDraft = message.labelIds?.includes(GmailLabel.DRAFT); const isInbox = message.labelIds?.includes(GmailLabel.INBOX); @@ -80,7 +62,7 @@ async function getMessages({ // Only show sent message that are in the inbox return isInbox; } - } else if (emailAccount.account.provider === "microsoft-entra-id") { + } else if (emailProvider.name === "microsoft-entra-id") { // For Outlook, we already filter out drafts in the message fetching // No additional filtering needed here } @@ -101,9 +83,9 @@ async function getMessages({ } } -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; - const userEmail = request.auth.email; +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; + const { emailAccountId, email: userEmail } = request.auth; const { searchParams } = new URL(request.url); const query = searchParams.get("q"); @@ -114,6 +96,7 @@ export const GET = withEmailAccount(async (request) => { query: r.q, pageToken: r.pageToken, userEmail, + emailProvider, }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/outlook/webhook/process-history.ts b/apps/web/app/api/outlook/webhook/process-history.ts index d5008fba67..fe59b08cf9 100644 --- a/apps/web/app/api/outlook/webhook/process-history.ts +++ b/apps/web/app/api/outlook/webhook/process-history.ts @@ -4,7 +4,8 @@ import prisma from "@/utils/prisma"; import { hasAiAccess, isPremium } from "@/utils/premium"; import { ColdEmailSetting } from "@prisma/client"; import { captureException } from "@/utils/error"; -import { unwatchEmails } from "@/app/api/outlook/watch/controller"; +import { unwatchEmails } from "@/app/api/watch/controller"; +import { createEmailProvider } from "@/utils/email/provider"; import type { ProcessHistoryOptions, OutlookResourceData, @@ -31,6 +32,7 @@ export async function processHistoryForUser({ autoCategorizeSenders: true, account: { select: { + provider: true, access_token: true, refresh_token: true, expires_at: true, @@ -76,11 +78,13 @@ export async function processHistoryForUser({ stripeSubscriptionStatus: emailAccount.user.premium?.stripeSubscriptionStatus, }); + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: emailAccount.account?.provider || "microsoft-entra-id", + }); await unwatchEmails({ emailAccountId: emailAccount.id, - accessToken: emailAccount.account?.access_token, - refreshToken: emailAccount.account?.refresh_token, - expiresAt: emailAccount.account?.expires_at, + provider, subscriptionId, }); return NextResponse.json({ ok: true }); @@ -90,11 +94,13 @@ export async function processHistoryForUser({ if (!userHasAiAccess) { logger.trace("Does not have ai access", { email: emailAccount.email }); + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: emailAccount.account?.provider || "microsoft-entra-id", + }); await unwatchEmails({ emailAccountId: emailAccount.id, - accessToken: emailAccount.account?.access_token, - refreshToken: emailAccount.account?.refresh_token, - expiresAt: emailAccount.account?.expires_at, + provider, subscriptionId, }); return NextResponse.json({ ok: true }); diff --git a/apps/web/app/api/threads/[id]/route.ts b/apps/web/app/api/threads/[id]/route.ts index 1fcdad7700..38079d51b6 100644 --- a/apps/web/app/api/threads/[id]/route.ts +++ b/apps/web/app/api/threads/[id]/route.ts @@ -1,12 +1,8 @@ import { z } from "zod"; import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; -import { createEmailProvider } from "@/utils/email/provider"; -import { parseMessages } from "@/utils/mail"; -import prisma from "@/utils/prisma"; -import { getCategory } from "@/utils/redis/category"; -import { ExecutedRuleStatus } from "@prisma/client"; +import { withEmailProvider } from "@/utils/middleware"; import { createScopedLogger } from "@/utils/logger"; +import { EmailProvider } from "@/utils/email/provider"; const threadQuery = z.object({ id: z.string() }); export type ThreadQuery = z.infer; @@ -17,35 +13,10 @@ const logger = createScopedLogger("api/threads/[id]"); async function getThread( id: string, includeDrafts: boolean, - emailAccountId: string, + emailProvider: EmailProvider, ) { - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { - provider: true, - }, - }, - }, - }); - - if (!emailAccount) { - throw new Error("Email account not found"); - } - - const provider = emailAccount.account.provider; - const emailProvider = await createEmailProvider({ - emailAccountId, - provider, - }); - - // Get the thread using the provider const thread = await emailProvider.getThread(id); - // For the unified API, we return the thread with parsed messages - // The parseMessages function expects a different format, so we handle it differently const filteredMessages = includeDrafts ? thread.messages : thread.messages.filter((msg) => !msg.labelIds?.includes("DRAFT")); @@ -57,8 +28,9 @@ export const dynamic = "force-dynamic"; export const maxDuration = 30; -export const GET = withEmailAccount(async (request, context) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailProvider(async (request, context) => { + const { emailProvider } = request; + const { emailAccountId } = request.auth; const params = await context.params; const { id } = threadQuery.parse(params); @@ -67,7 +39,7 @@ export const GET = withEmailAccount(async (request, context) => { const includeDrafts = searchParams.get("includeDrafts") === "true"; try { - const thread = await getThread(id, includeDrafts, emailAccountId); + const thread = await getThread(id, includeDrafts, emailProvider); return NextResponse.json(thread); } catch (error) { logger.error("Error fetching thread", { diff --git a/apps/web/app/api/threads/basic/route.ts b/apps/web/app/api/threads/basic/route.ts index 4e4acfc997..9299d2e716 100644 --- a/apps/web/app/api/threads/basic/route.ts +++ b/apps/web/app/api/threads/basic/route.ts @@ -1,54 +1,34 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; -import { createEmailProvider } from "@/utils/email/provider"; +import { withEmailProvider } from "@/utils/middleware"; import { createScopedLogger } from "@/utils/logger"; -import prisma from "@/utils/prisma"; +import type { ThreadsResponse } from "@/app/api/threads/route"; const logger = createScopedLogger("api/threads/basic"); export type GetThreadsResponse = { - threads: any[]; + threads: ThreadsResponse["threads"]; }; export const dynamic = "force-dynamic"; export const maxDuration = 30; -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; + const { emailAccountId } = request.auth; const { searchParams } = new URL(request.url); - const folderId = searchParams.get("folderId"); + const fromEmail = searchParams.get("fromEmail"); + const labelId = searchParams.get("labelId"); try { - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { - provider: true, - }, - }, + const { threads } = await emailProvider.getThreadsWithQuery({ + query: { + fromEmail, + labelId, }, }); - if (!emailAccount) { - return NextResponse.json( - { error: "Email account not found" }, - { status: 404 }, - ); - } - - const provider = emailAccount.account.provider; - const emailProvider = await createEmailProvider({ - emailAccountId, - provider, - }); - - // Get basic threads using the provider - const threads = await emailProvider.getThreads(folderId || undefined); - return NextResponse.json({ threads }); } catch (error) { logger.error("Error fetching basic threads", { error, emailAccountId }); diff --git a/apps/web/app/api/threads/batch/route.ts b/apps/web/app/api/threads/batch/route.ts index 72d2378404..94e30d8dbe 100644 --- a/apps/web/app/api/threads/batch/route.ts +++ b/apps/web/app/api/threads/batch/route.ts @@ -1,21 +1,21 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; -import { createEmailProvider } from "@/utils/email/provider"; +import { withEmailProvider } from "@/utils/middleware"; import { createScopedLogger } from "@/utils/logger"; -import prisma from "@/utils/prisma"; +import type { ThreadsResponse } from "@/app/api/threads/route"; const logger = createScopedLogger("api/threads/batch"); export type ThreadsBatchResponse = { - threads: any[]; + threads: ThreadsResponse["threads"]; }; export const dynamic = "force-dynamic"; export const maxDuration = 30; -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; + const { emailAccountId } = request.auth; const { searchParams } = new URL(request.url); const threadIdsParam = searchParams.get("threadIds"); @@ -34,31 +34,6 @@ export const GET = withEmailAccount(async (request) => { } try { - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { - provider: true, - }, - }, - }, - }); - - if (!emailAccount) { - return NextResponse.json( - { error: "Email account not found" }, - { status: 404 }, - ); - } - - const provider = emailAccount.account.provider; - const emailProvider = await createEmailProvider({ - emailAccountId, - provider, - }); - // Get threads using the provider const threads = await Promise.all( threadIds.map(async (threadId) => { @@ -72,7 +47,7 @@ export const GET = withEmailAccount(async (request) => { ); const validThreads = threads.filter( - (thread): thread is any => thread !== null, + (thread): thread is ThreadsResponse["threads"][number] => thread !== null, ); return NextResponse.json({ threads: validThreads }); diff --git a/apps/web/app/api/threads/route.ts b/apps/web/app/api/threads/route.ts index b9504a6e26..1535fb703b 100644 --- a/apps/web/app/api/threads/route.ts +++ b/apps/web/app/api/threads/route.ts @@ -1,54 +1,73 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; -import { threadsQuery } from "@/app/api/threads/validation"; -import { createEmailProvider } from "@/utils/email/provider"; +import { withEmailProvider } from "@/utils/middleware"; +import { ThreadsQuery, threadsQuery } from "@/app/api/threads/validation"; import { isDefined } from "@/utils/types"; import prisma from "@/utils/prisma"; import { getCategory } from "@/utils/redis/category"; import { ExecutedRuleStatus } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; import { isIgnoredSender } from "@/utils/filter-ignored-senders"; +import { EmailProvider } from "@/utils/email/provider"; const logger = createScopedLogger("api/threads"); +export const dynamic = "force-dynamic"; + +export const maxDuration = 30; + +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; + const { emailAccountId } = request.auth; + + const { searchParams } = new URL(request.url); + const limit = searchParams.get("limit"); + const fromEmail = searchParams.get("fromEmail"); + const type = searchParams.get("type"); + const nextPageToken = searchParams.get("nextPageToken"); + const q = searchParams.get("q"); + const labelId = searchParams.get("labelId"); + + const query = threadsQuery.parse({ + limit, + fromEmail, + type, + nextPageToken, + q, + labelId, + }); + + try { + const threads = await getThreads({ + query, + emailAccountId, + emailProvider, + }); + return NextResponse.json(threads); + } catch (error) { + logger.error("Error fetching threads", { error, emailAccountId }); + return NextResponse.json( + { error: "Failed to fetch threads" }, + { status: 500 }, + ); + } +}); + export type ThreadsResponse = Awaited>; async function getThreads({ query, emailAccountId, - userEmail, + emailProvider, }: { - query: any; + query: ThreadsQuery; emailAccountId: string; - userEmail: string; + emailProvider: EmailProvider; }) { - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { - provider: true, - }, - }, - }, - }); - - if (!emailAccount) { - throw new Error("Email account not found"); - } - - const provider = emailAccount.account.provider; - const emailProvider = await createEmailProvider({ - emailAccountId, - provider, - }); - // Get threads using the provider const { threads, nextPageToken } = await emailProvider.getThreadsWithQuery({ query, maxResults: query.limit || 50, - pageToken: query.nextPageToken, + pageToken: query.nextPageToken || undefined, }); // Get executed rules for these threads @@ -98,46 +117,3 @@ async function getThreads({ nextPageToken, }; } - -export const dynamic = "force-dynamic"; - -export const maxDuration = 30; - -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; - const userEmail = request.auth.email; - - const { searchParams } = new URL(request.url); - const limit = searchParams.get("limit"); - const fromEmail = searchParams.get("fromEmail"); - const type = searchParams.get("type"); - const nextPageToken = searchParams.get("nextPageToken"); - const q = searchParams.get("q"); - const labelId = searchParams.get("labelId"); - const folderId = searchParams.get("folderId"); - - const query = threadsQuery.parse({ - limit, - fromEmail, - type, - nextPageToken, - q, - labelId, - folderId, - }); - - try { - const threads = await getThreads({ - query, - emailAccountId, - userEmail, - }); - return NextResponse.json(threads); - } catch (error) { - logger.error("Error fetching threads", { error, emailAccountId }); - return NextResponse.json( - { error: "Failed to fetch threads" }, - { status: 500 }, - ); - } -}); diff --git a/apps/web/app/api/threads/validation.ts b/apps/web/app/api/threads/validation.ts index 5eb4a5b3d3..97ea43c041 100644 --- a/apps/web/app/api/threads/validation.ts +++ b/apps/web/app/api/threads/validation.ts @@ -7,6 +7,5 @@ export const threadsQuery = z.object({ q: z.string().nullish(), nextPageToken: z.string().nullish(), labelId: z.string().nullish(), // For Google - folderId: z.string().nullish(), // For Microsoft }); export type ThreadsQuery = z.infer; diff --git a/apps/web/app/api/user/stats/newsletters/helpers.ts b/apps/web/app/api/user/stats/newsletters/helpers.ts index a39e32b395..eb40baae9a 100644 --- a/apps/web/app/api/user/stats/newsletters/helpers.ts +++ b/apps/web/app/api/user/stats/newsletters/helpers.ts @@ -11,7 +11,9 @@ export async function getAutoArchiveFilters(emailProvider: EmailProvider) { try { const filters = await emailProvider.getFiltersList(); - const autoArchiveFilters = filters.filter(isAutoArchiveFilter); + const autoArchiveFilters = filters.filter((filter) => + isAutoArchiveFilter(filter, emailProvider), + ); return autoArchiveFilters; } catch (error) { @@ -24,12 +26,13 @@ export async function getAutoArchiveFilters(emailProvider: EmailProvider) { export function findAutoArchiveFilter( autoArchiveFilters: EmailFilter[], fromEmail: string, + emailProvider: EmailProvider, ) { return autoArchiveFilters.find((filter) => { const from = extractEmailAddress(fromEmail); return ( filter.criteria?.from?.toLowerCase().includes(from.toLowerCase()) && - isAutoArchiveFilter(filter) + isAutoArchiveFilter(filter, emailProvider) ); }); } @@ -75,16 +78,26 @@ export function filterNewsletters< }); } -function isAutoArchiveFilter(filter: EmailFilter) { +function isAutoArchiveFilter(filter: EmailFilter, provider: EmailProvider) { + switch (provider.name) { + case "google": + return isGmailAutoArchiveFilter(filter); + case "microsoft-entra-id": + return isOutlookAutoArchiveFilter(filter); + default: + return false; + } +} + +function isGmailAutoArchiveFilter(filter: EmailFilter): boolean { // For Gmail: check if it removes INBOX label or adds TRASH label - const isGmailArchive = + return Boolean( filter.action?.removeLabelIds?.includes(GmailLabel.INBOX) || - filter.action?.addLabelIds?.includes(GmailLabel.TRASH); + filter.action?.addLabelIds?.includes(GmailLabel.TRASH), + ); +} +function isOutlookAutoArchiveFilter(filter: EmailFilter): boolean { // For Outlook: check if it moves to archive folder (removeLabelIds contains "INBOX") - const isOutlookArchive = filter.action?.removeLabelIds?.includes("INBOX"); - - const result = isGmailArchive || isOutlookArchive; - - return result; + return Boolean(filter.action?.removeLabelIds?.includes("INBOX")); } diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index 2d69830827..7ca8e8e681 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -1,19 +1,19 @@ import { NextResponse } from "next/server"; +import { withEmailProvider } from "@/utils/middleware"; +import { extractEmailAddress } from "@/utils/email"; +import { createScopedLogger } from "@/utils/logger"; +import prisma from "@/utils/prisma"; +import { Prisma } from "@prisma/client"; import { z } from "zod"; -import { withEmailAccount } from "@/utils/middleware"; +import { EmailProvider } from "@/utils/email/provider"; import { - filterNewsletters, - findAutoArchiveFilter, - findNewsletterStatus, getAutoArchiveFilters, + findNewsletterStatus, + findAutoArchiveFilter, + filterNewsletters, } from "@/app/api/user/stats/newsletters/helpers"; -import prisma from "@/utils/prisma"; -import { Prisma } from "@prisma/client"; -import { extractEmailAddress } from "@/utils/email"; -import { createEmailProvider } from "@/utils/email/provider"; -import { createScopedLogger } from "@/utils/logger"; -const logger = createScopedLogger("newsletter-stats"); +const logger = createScopedLogger("api/user/stats/newsletters"); const newsletterStatsQuery = z.object({ limit: z.coerce.number().nullish(), @@ -31,6 +31,7 @@ const newsletterStatsQuery = z.object({ .transform((arr) => arr?.filter(Boolean)), includeMissingUnsubscribe: z.boolean().optional(), }); + export type NewsletterStatsQuery = z.infer; export type NewsletterStatsResponse = Awaited< ReturnType @@ -67,30 +68,14 @@ function getTypeFilters(types: NewsletterStatsQuery["types"]) { } async function getEmailMessages( - options: { emailAccountId: string } & NewsletterStatsQuery, + options: { + emailAccountId: string; + emailProvider: EmailProvider; + } & NewsletterStatsQuery, ) { - const { emailAccountId } = options; + const { emailAccountId, emailProvider } = options; const types = getTypeFilters(options.types); - // Get the email account to determine the provider - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { provider: true }, - }, - }, - }); - - if (!emailAccount) { - throw new Error("Email account not found"); - } - - const emailProvider = await createEmailProvider({ - emailAccountId, - provider: emailAccount.account.provider, - }); - const [counts, autoArchiveFilters, userNewsletters] = await Promise.all([ getNewsletterCounts({ ...options, @@ -108,7 +93,11 @@ async function getEmailMessages( inboxEmails: email.inboxEmails, readEmails: email.readEmails, unsubscribeLink: email.unsubscribeLink, - autoArchived: findAutoArchiveFilter(autoArchiveFilters, from), + autoArchived: findAutoArchiveFilter( + autoArchiveFilters, + from, + emailProvider, + ), status: userNewsletters?.find((n) => n.email === from)?.status, }; }); @@ -233,8 +222,8 @@ async function getNewsletterCounts( } catch (error) { logger.error("getNewsletterCounts error", { error, - errorMessage: (error as any)?.message, - errorStack: (error as any)?.stack, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, }); return []; } @@ -253,17 +242,9 @@ function getOrderByClause(orderBy: string): string { } } -// Helper function to extract unsubscribe links from email content -function extractUnsubscribeLink(content: string): string | null { - // Simple regex to find unsubscribe links - const unsubscribeRegex = - /(?:unsubscribe|opt.?out|remove).*?(?:https?:\/\/[^\s<>"']+)/gi; - const match = unsubscribeRegex.exec(content); - return match ? match[0] : null; -} - -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; +export const GET = withEmailProvider(async (request) => { + const { emailProvider } = request; + const { emailAccountId } = request.auth; const { searchParams } = new URL(request.url); const params = newsletterStatsQuery.parse({ @@ -280,6 +261,7 @@ export const GET = withEmailAccount(async (request) => { const result = await getEmailMessages({ ...params, emailAccountId, + emailProvider, }); return NextResponse.json(result); diff --git a/apps/web/app/api/watch/all/route.ts b/apps/web/app/api/watch/all/route.ts new file mode 100644 index 0000000000..4c811ce050 --- /dev/null +++ b/apps/web/app/api/watch/all/route.ts @@ -0,0 +1,147 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { watchEmails } from "../controller"; +import { createEmailProvider } from "@/utils/email/provider"; +import { hasCronSecret, hasPostCronSecret } from "@/utils/cron"; +import { withError } from "@/utils/middleware"; +import { captureException } from "@/utils/error"; +import { hasAiAccess } from "@/utils/premium"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("api/watch/all"); + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +async function watchAllEmails() { + const emailAccounts = await prisma.emailAccount.findMany({ + where: { + user: { + premium: { + OR: [ + { lemonSqueezyRenewsAt: { gt: new Date() } }, + { stripeSubscriptionStatus: { in: ["active", "trialing"] } }, + ], + }, + }, + }, + select: { + id: true, + email: true, + watchEmailsExpirationDate: true, + watchEmailsSubscriptionId: true, + account: { + select: { + provider: true, + access_token: true, + refresh_token: true, + expires_at: true, + }, + }, + user: { + select: { + aiApiKey: true, + premium: { select: { tier: true } }, + }, + }, + }, + orderBy: { + watchEmailsExpirationDate: { sort: "asc", nulls: "first" }, + }, + }); + + logger.info("Watching email accounts", { count: emailAccounts.length }); + + for (const emailAccount of emailAccounts) { + try { + logger.info("Watching emails for account", { + emailAccountId: emailAccount.id, + email: emailAccount.email, + provider: emailAccount.account.provider, + }); + + const userHasAiAccess = hasAiAccess( + emailAccount.user.premium?.tier || null, + emailAccount.user.aiApiKey, + ); + + if (!userHasAiAccess) { + logger.info("User does not have access to AI or cold email", { + email: emailAccount.email, + }); + if ( + emailAccount.watchEmailsExpirationDate && + new Date(emailAccount.watchEmailsExpirationDate) < new Date() + ) { + await prisma.emailAccount.update({ + where: { email: emailAccount.email }, + data: { + watchEmailsExpirationDate: null, + watchEmailsSubscriptionId: null, + }, + }); + } + + continue; + } + + if ( + !emailAccount.account?.access_token || + !emailAccount.account?.refresh_token + ) { + logger.info("User has no access token or refresh token", { + email: emailAccount.email, + }); + continue; + } + + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: emailAccount.account.provider, + }); + + await watchEmails({ + emailAccountId: emailAccount.id, + provider, + }); + } catch (error) { + if (error instanceof Error) { + const warn = [ + "invalid_grant", + "Mail service not enabled", + "Insufficient Permission", + ]; + + if (warn.some((w) => error.message.includes(w))) { + logger.warn("Not watching emails for user", { + email: emailAccount.email, + error, + }); + continue; + } + } + + logger.error("Error for user", { email: emailAccount.email, error }); + } + } + + return NextResponse.json({ success: true }); +} + +export const GET = withError(async (request) => { + if (!hasCronSecret(request)) { + captureException(new Error("Unauthorized cron request: api/watch/all")); + return new Response("Unauthorized", { status: 401 }); + } + + return watchAllEmails(); +}); + +export const POST = withError(async (request) => { + if (!(await hasPostCronSecret(request))) { + captureException(new Error("Unauthorized cron request: api/watch/all")); + return new Response("Unauthorized", { status: 401 }); + } + + return watchAllEmails(); +}); diff --git a/apps/web/app/api/watch/controller.ts b/apps/web/app/api/watch/controller.ts new file mode 100644 index 0000000000..30299f3ab4 --- /dev/null +++ b/apps/web/app/api/watch/controller.ts @@ -0,0 +1,90 @@ +import prisma from "@/utils/prisma"; +import { captureException } from "@/utils/error"; +import { createScopedLogger } from "@/utils/logger"; +import type { EmailProvider } from "@/utils/email/provider"; + +const logger = createScopedLogger("watch/controller"); + +export async function watchEmails({ + emailAccountId, + provider, +}: { + emailAccountId: string; + provider: EmailProvider; +}) { + logger.info("Watching emails", { + emailAccountId, + providerName: provider.name, + }); + + try { + const result = await provider.watchEmails(); + + if (result) { + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { + watchEmailsExpirationDate: result.expirationDate, + watchEmailsSubscriptionId: + provider.name === "microsoft-entra-id" + ? result.subscriptionId + : null, + }, + }); + + return result.expirationDate; + } + } catch (error) { + logger.error("Error watching inbox", { + emailAccountId, + providerName: provider.name, + error, + }); + captureException(error); + } + + return null; +} + +export async function unwatchEmails({ + emailAccountId, + provider, + subscriptionId, +}: { + emailAccountId: string; + provider: EmailProvider; + subscriptionId?: string | null; +}) { + try { + logger.info("Unwatching emails", { + emailAccountId, + providerName: provider.name, + }); + + await provider.unwatchEmails(subscriptionId || undefined); + } catch (error) { + if (error instanceof Error && error.message.includes("invalid_grant")) { + logger.warn("Error unwatching emails, invalid grant", { + emailAccountId, + providerName: provider.name, + }); + return; + } + + logger.error("Error unwatching emails", { + emailAccountId, + providerName: provider.name, + error, + }); + captureException(error); + } + + // Clear the watch data regardless of provider + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { + watchEmailsExpirationDate: null, + watchEmailsSubscriptionId: null, + }, + }); +} diff --git a/apps/web/app/api/watch/route.ts b/apps/web/app/api/watch/route.ts new file mode 100644 index 0000000000..c3a18fa372 --- /dev/null +++ b/apps/web/app/api/watch/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import { withAuth, withEmailProvider } from "@/utils/middleware"; +import { createScopedLogger } from "@/utils/logger"; +import prisma from "@/utils/prisma"; +import { watchEmails } from "./controller"; +import { createEmailProvider } from "@/utils/email/provider"; + +export const dynamic = "force-dynamic"; + +const logger = createScopedLogger("api/watch"); + +export const GET = withAuth(async (request) => { + const userId = request.auth.userId; + const results = []; + + const emailAccounts = await prisma.emailAccount.findMany({ + where: { userId }, + select: { + id: true, + account: { + select: { + provider: true, + access_token: true, + refresh_token: true, + expires_at: true, + }, + }, + }, + }); + + if (emailAccounts.length === 0) { + return NextResponse.json( + { message: "No email accounts found for this user." }, + { status: 404 }, + ); + } + + for (const { id: emailAccountId, account } of emailAccounts) { + try { + // Check for missing tokens for Microsoft accounts + if (!account.access_token || !account.refresh_token) { + logger.warn("Missing tokens for account", { emailAccountId }); + results.push({ + emailAccountId, + status: "error", + message: "Missing authentication tokens.", + }); + continue; + } + + // Create email provider for this account + const provider = await createEmailProvider({ + emailAccountId, + provider: account.provider, + }); + + const expirationDate = await watchEmails({ + emailAccountId, + provider, + }); + + if (expirationDate) { + results.push({ + emailAccountId, + status: "success", + expirationDate, + }); + } else { + logger.error("Error watching inbox for account", { emailAccountId }); + results.push({ + emailAccountId, + status: "error", + message: "Failed to set up watch for this account.", + }); + } + } catch (error) { + logger.error("Exception while watching inbox for account", { + emailAccountId, + error, + }); + results.push({ + emailAccountId, + status: "error", + message: + "An unexpected error occurred while setting up watch for this account.", + errorDetails: error instanceof Error ? error.message : String(error), + }); + } + } + + return NextResponse.json({ results }); +}); diff --git a/apps/web/app/api/watch/unwatch/route.ts b/apps/web/app/api/watch/unwatch/route.ts new file mode 100644 index 0000000000..e39291bdc6 --- /dev/null +++ b/apps/web/app/api/watch/unwatch/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { withEmailProvider } from "@/utils/middleware"; +import { createScopedLogger } from "@/utils/logger"; +import prisma from "@/utils/prisma"; +import { unwatchEmails } from "../controller"; + +export const dynamic = "force-dynamic"; + +const logger = createScopedLogger("api/watch/unwatch"); + +export const POST = withEmailProvider(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + // Get the subscription ID for this account + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + watchEmailsSubscriptionId: true, + }, + }); + + try { + await unwatchEmails({ + emailAccountId, + provider: request.emailProvider, + subscriptionId: emailAccount?.watchEmailsSubscriptionId, + }); + + return NextResponse.json({ + status: "success", + message: "Successfully unwatched emails for this account.", + }); + } catch (error) { + logger.error("Exception while unwatching emails for account", { + emailAccountId, + error, + }); + return NextResponse.json( + { + status: "error", + message: "An unexpected error occurred while unwatching this account.", + errorDetails: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +}); diff --git a/apps/web/components/EmailMessageCell.tsx b/apps/web/components/EmailMessageCell.tsx index f2d092d208..4a14e2b13a 100644 --- a/apps/web/components/EmailMessageCell.tsx +++ b/apps/web/components/EmailMessageCell.tsx @@ -3,7 +3,7 @@ import { ExternalLinkIcon } from "lucide-react"; import Link from "next/link"; import { MessageText } from "@/components/Typography"; -import { getEmailUrl } from "@/utils/url"; +import { getEmailUrlForMessage } from "@/utils/url"; import { decodeSnippet } from "@/utils/gmail/decode"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { useThread } from "@/hooks/useThread"; @@ -94,11 +94,7 @@ export function EmailMessageCell({ {" "} diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index e066824d9e..ecddc49725 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -45,7 +45,7 @@ import { addToArchiveSenderQueue, useArchiveSenderStatus, } from "@/store/archive-sender-queue"; -import { getEmailUrl, getGmailSearchUrl, getGmailUrl } from "@/utils/url"; +import { getEmailUrl, getGmailSearchUrl } from "@/utils/url"; import { MessageText } from "@/components/Typography"; import { CreateCategoryDialog } from "@/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton"; import { @@ -75,7 +75,7 @@ export function GroupedTable({ emailGroups: EmailGroup[]; categories: CategoryWithRules[]; }) { - const { emailAccountId, userEmail, provider } = useAccount(); + const { emailAccountId, userEmail } = useAccount(); const categoryMap = useMemo(() => { return categories.reduce>( @@ -212,7 +212,6 @@ export function GroupedTable({ await addToArchiveSenderQueue({ sender: sender.address, emailAccountId, - provider, }); } }; diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index 96b8ff4980..5f9877e2fd 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -68,8 +68,7 @@ type NavItem = { export const useNavigation = () => { // When we have features in early access, we can filter the navigation items const showCleaner = useCleanerEnabled(); - const { emailAccountId } = useAccount(); - const { provider } = useAccount(); + const { emailAccountId, provider } = useAccount(); // Assistant category items const assistantItems: NavItem[] = useMemo( diff --git a/apps/web/env.ts b/apps/web/env.ts index 4bade11114..28db8d92fc 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -10,12 +10,10 @@ export const env = createEnv({ NEXTAUTH_URL: z.string().optional(), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), - GOOGLE_ENCRYPT_SECRET: z.string(), - GOOGLE_ENCRYPT_SALT: z.string(), + EMAIL_ENCRYPT_SECRET: z.string(), + EMAIL_ENCRYPT_SALT: z.string(), MICROSOFT_CLIENT_ID: z.string().optional(), MICROSOFT_CLIENT_SECRET: z.string().optional(), - MICROSOFT_ENCRYPT_SECRET: z.string().optional(), - MICROSOFT_ENCRYPT_SALT: z.string().optional(), DEFAULT_LLM_PROVIDER: z .enum([ "anthropic", diff --git a/apps/web/hooks/useLabels.ts b/apps/web/hooks/useLabels.ts index 82c11a28de..ee7931f6c4 100644 --- a/apps/web/hooks/useLabels.ts +++ b/apps/web/hooks/useLabels.ts @@ -28,7 +28,10 @@ type SortableLabel = { id: string | null | undefined; name: string | null | undefined; type: string | null; - color?: any; + color?: { + textColor?: string | null; + backgroundColor?: string | null; + }; }; function isHidden(label: EmailLabel): boolean { diff --git a/apps/web/hooks/useThreads.ts b/apps/web/hooks/useThreads.ts index dd1e614205..72438dfac7 100644 --- a/apps/web/hooks/useThreads.ts +++ b/apps/web/hooks/useThreads.ts @@ -16,7 +16,6 @@ export function useThreads({ limit?: number; refreshInterval?: number; }) { - const { provider } = useAccount(); const searchParams = new URLSearchParams(); if (fromEmail) searchParams.set("fromEmail", fromEmail); if (limit) searchParams.set("limit", limit.toString()); diff --git a/apps/web/package.json b/apps/web/package.json index 0d3cb45453..2e6edff0c7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,7 +36,6 @@ "@mdx-js/loader": "3.1.0", "@mdx-js/react": "3.1.0", "@microsoft/microsoft-graph-client": "3.0.7", - "@microsoft/microsoft-graph-types": "2.40.0", "@mux/mux-player-react": "3.4.0", "@next/mdx": "15.3.3", "@next/third-parties": "15.3.3", @@ -150,6 +149,7 @@ "devDependencies": { "@headlessui/tailwindcss": "0.2.2", "@inboxzero/eslint-config": "workspace:*", + "@microsoft/microsoft-graph-types": "^2.40.0", "@testing-library/react": "16.3.0", "@types/diff": "7.0.2", "@types/email-reply-parser": "1.4.2", diff --git a/apps/web/providers/EmailProvider.tsx b/apps/web/providers/EmailProvider.tsx index 12636e074e..d8409ee61a 100644 --- a/apps/web/providers/EmailProvider.tsx +++ b/apps/web/providers/EmailProvider.tsx @@ -31,32 +31,38 @@ const EmailContext = createContext({ export const useEmail = () => useContext(EmailContext); +function mapLabelColor(provider: string, label: any): EmailLabel["color"] { + if (!provider) { + return undefined; + } + + if (provider === "google") { + return label.color; + } else if (provider === "microsoft-entra-id") { + const presetColor = label.color as string; + const backgroundColor = + OUTLOOK_COLOR_MAP[presetColor as keyof typeof OUTLOOK_COLOR_MAP] || + "#95A5A6"; // Default gray if preset not found + + return { + backgroundColor, + textColor: null, + }; + } + + throw new Error(`Unsupported provider: ${provider}`); +} + export function EmailProvider(props: { children: React.ReactNode }) { - const { provider } = useAccount(); + const { provider, isLoading: accountIsLoading } = useAccount(); const { userLabels: rawUserLabels, isLoading } = useLabels(); const userLabels = useMemo(() => { - if (!rawUserLabels) return {}; + if (!rawUserLabels || !provider || accountIsLoading) return {}; return rawUserLabels.reduce((acc, label) => { if (label.id && label.name) { - let color: EmailLabel["color"]; - - if (provider === "google") { - // For Google, color is already in the correct format - color = label.color; - } else { - // For Outlook, map the preset color string to actual color value - const presetColor = label.color as string; - const backgroundColor = - OUTLOOK_COLOR_MAP[presetColor as keyof typeof OUTLOOK_COLOR_MAP] || - "#95A5A6"; // Default gray if preset not found - - color = { - backgroundColor, - textColor: null, - }; - } + const color = mapLabelColor(provider, label); acc[label.id] = { id: label.id, @@ -69,11 +75,11 @@ export function EmailProvider(props: { children: React.ReactNode }) { } return acc; }, {} as EmailLabels); - }, [rawUserLabels, provider]); + }, [rawUserLabels, provider, accountIsLoading]); const value = useMemo( - () => ({ userLabels, labelsIsLoading: isLoading }), - [userLabels, isLoading], + () => ({ userLabels, labelsIsLoading: isLoading || accountIsLoading }), + [userLabels, isLoading, accountIsLoading], ); return ( diff --git a/apps/web/store/archive-sender-queue.ts b/apps/web/store/archive-sender-queue.ts index 4a35b95b5d..fe35cd76d6 100644 --- a/apps/web/store/archive-sender-queue.ts +++ b/apps/web/store/archive-sender-queue.ts @@ -22,14 +22,12 @@ export async function addToArchiveSenderQueue({ onSuccess, onError, emailAccountId, - provider, }: { sender: string; labelId?: string; onSuccess?: (totalThreads: number) => void; onError?: (sender: string) => void; emailAccountId: string; - provider: string; }) { // Add sender with pending status jotaiStore.set(archiveSenderQueueAtom, (prev) => { @@ -45,10 +43,11 @@ export async function addToArchiveSenderQueue({ const data = await fetchSenderThreads({ sender, emailAccountId, - provider, }); const threads = data.threads; - const threadIds = threads.map((t: any) => t.id).filter(isDefined); + const threadIds = threads + .map((t: { id: string }) => t.id) + .filter(isDefined); // Update with thread IDs jotaiStore.set(archiveSenderQueueAtom, (prev) => { @@ -124,11 +123,9 @@ export const useArchiveSenderStatus = (sender: string) => { async function fetchSenderThreads({ sender, emailAccountId, - provider, }: { sender: string; emailAccountId: string; - provider: string; }) { const url = `/api/threads/basic?fromEmail=${encodeURIComponent(sender)}&labelId=INBOX`; const res = await fetchWithAccount({ diff --git a/apps/web/utils/actions/admin.ts b/apps/web/utils/actions/admin.ts index fb82ab1c3b..36f30d5af5 100644 --- a/apps/web/utils/actions/admin.ts +++ b/apps/web/utils/actions/admin.ts @@ -1,8 +1,6 @@ "use server"; import { z } from "zod"; -import { processHistoryForUser as processGmailHistory } from "@/app/api/google/webhook/process-history"; -import { processHistoryForUser as processOutlookHistory } from "@/app/api/outlook/webhook/process-history"; import type Stripe from "stripe"; import { createScopedLogger } from "@/utils/logger"; import { deleteUser } from "@/utils/user/delete"; @@ -11,6 +9,7 @@ import { adminActionClient } from "@/utils/actions/safe-action"; import { SafeError } from "@/utils/error"; import { syncStripeDataToDb } from "@/ee/billing/stripe/sync-stripe"; import { getStripe } from "@/ee/billing/stripe"; +import { createEmailProvider } from "@/utils/email/provider"; const logger = createScopedLogger("Admin Action"); @@ -29,11 +28,13 @@ export const adminProcessHistoryAction = adminActionClient const emailAccount = await prisma.emailAccount.findUnique({ where: { email: emailAddress.toLowerCase() }, select: { + id: true, account: { select: { provider: true, }, }, + watchEmailsSubscriptionId: true, }, }); @@ -43,43 +44,27 @@ export const adminProcessHistoryAction = adminActionClient const provider = emailAccount.account?.provider; - if (provider === "google") { - await processGmailHistory( - { - emailAddress, - historyId: historyId ? historyId : 0, - }, - { - startHistoryId: startHistoryId - ? startHistoryId.toString() - : undefined, - }, - ); - } else if (provider === "microsoft-entra-id") { - // For Outlook, we need to get the subscription ID - const subscription = await prisma.emailAccount.findUnique({ - where: { email: emailAddress.toLowerCase() }, - select: { - watchEmailsSubscriptionId: true, - }, - }); + if (!provider) { + throw new SafeError("No provider found for email account"); + } - if (!subscription?.watchEmailsSubscriptionId) { - throw new SafeError("No subscription ID found for Outlook account"); - } + // Create the email provider + const emailProvider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider, + }); - // For Outlook, we need to get the message ID from the history ID - // This is a simplified version - you might need to adjust this based on your needs - await processOutlookHistory({ - subscriptionId: subscription.watchEmailsSubscriptionId, - resourceData: { - id: historyId?.toString() || "0", - conversationId: startHistoryId?.toString(), - }, - }); - } else { - throw new SafeError(`Unsupported provider: ${provider}`); - } + // Use the unified processHistory method + await emailProvider.processHistory({ + emailAddress, + historyId, + startHistoryId, + subscriptionId: emailAccount.watchEmailsSubscriptionId || undefined, + resourceData: { + id: historyId?.toString() || "0", + conversationId: startHistoryId?.toString(), + }, + }); }, ); diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 8c8fefbf7a..7d86319519 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -408,18 +408,38 @@ const refreshAccessToken = async (token: JWT): Promise => { const provider = PROVIDER_CONFIG[account.provider as keyof typeof PROVIDER_CONFIG]; + const getProviderCredentials = ( + provider: string, + ): { clientId: string; clientSecret: string } => { + if (provider === "google") { + return { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + }; + } + + if (provider === "microsoft-entra-id") { + if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) { + logger.error("Microsoft login not enabled - missing credentials"); + throw new Error("Microsoft login not enabled - missing credentials"); + } + return { + clientId: env.MICROSOFT_CLIENT_ID, + clientSecret: env.MICROSOFT_CLIENT_SECRET, + }; + } + + logger.error(`Unsupported provider: ${provider}`); + throw new Error(`Unsupported provider: ${provider}`); + }; + try { + const { clientId, clientSecret } = getProviderCredentials(account.provider); const response = await fetch(provider.tokenUrl, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - client_id: - account.provider === "google" - ? env.GOOGLE_CLIENT_ID - : env.MICROSOFT_CLIENT_ID!, - client_secret: - account.provider === "google" - ? env.GOOGLE_CLIENT_SECRET - : env.MICROSOFT_CLIENT_SECRET!, + client_id: clientId, + client_secret: clientSecret, grant_type: "refresh_token", refresh_token: account.refresh_token, }), diff --git a/apps/web/utils/email/provider.ts b/apps/web/utils/email/provider.ts index c344ba4a18..f0d7728d1e 100644 --- a/apps/web/utils/email/provider.ts +++ b/apps/web/utils/email/provider.ts @@ -111,6 +111,10 @@ import { deleteFilter as deleteOutlookFilter, createAutoArchiveFilter as createOutlookAutoArchiveFilter, } from "@/utils/outlook/filter"; +import { processHistoryForUser as processGmailHistory } from "@/app/api/google/webhook/process-history"; +import { processHistoryForUser as processOutlookHistory } from "@/app/api/outlook/webhook/process-history"; +import { watchGmail, unwatchGmail } from "@/utils/gmail/watch"; +import { watchOutlook, unwatchOutlook } from "@/utils/outlook/watch"; const logger = createScopedLogger("email-provider"); @@ -146,6 +150,7 @@ export interface EmailFilter { } export interface EmailProvider { + readonly name: "google" | "microsoft-entra-id"; getThreads(folderId?: string): Promise; getThread(threadId: string): Promise; getLabels(): Promise; @@ -252,11 +257,26 @@ export interface EmailProvider { awaitingReplyLabelId: string; needsReplyLabelId: string; }>; + processHistory(options: { + emailAddress: string; + historyId?: number; + startHistoryId?: number; + subscriptionId?: string; + resourceData?: { + id: string; + conversationId?: string; + }; + }): Promise; + watchEmails(): Promise<{ + expirationDate: Date; + subscriptionId?: string; + } | null>; + unwatchEmails(subscriptionId?: string): Promise; } export class GmailProvider implements EmailProvider { + readonly name = "google"; private client: gmail_v1.Gmail; - constructor(client: gmail_v1.Gmail) { this.client = client; } @@ -802,9 +822,48 @@ export class GmailProvider implements EmailProvider { }> { return getReplyTrackingLabels(this.client); } + + async processHistory(options: { + emailAddress: string; + historyId?: number; + startHistoryId?: number; + subscriptionId?: string; + resourceData?: { + id: string; + conversationId?: string; + }; + }): Promise { + await processGmailHistory( + { + emailAddress: options.emailAddress, + historyId: options.historyId || 0, + }, + { + startHistoryId: options.startHistoryId?.toString(), + }, + ); + } + + async watchEmails(): Promise<{ + expirationDate: Date; + subscriptionId?: string; + } | null> { + const res = await watchGmail(this.client); + + if (res.expiration) { + const expirationDate = new Date(+res.expiration); + return { expirationDate }; + } + return null; + } + + async unwatchEmails(subscriptionId?: string): Promise { + await unwatchGmail(this.client); + } } export class OutlookProvider implements EmailProvider { + readonly name = "microsoft-entra-id"; private client: OutlookClient; constructor(client: OutlookClient) { @@ -1068,21 +1127,27 @@ export class OutlookProvider implements EmailProvider { try { const response = await getOutlookFiltersList({ client: this.client }); - const mappedFilters = (response.value || []).map((filter: any) => { - const mappedFilter = { - id: filter.id || "", - criteria: { - from: filter.conditions?.senderContains?.[0] || undefined, - }, - action: { - addLabelIds: filter.actions?.applyCategories || undefined, - removeLabelIds: filter.actions?.moveToFolder - ? ["INBOX"] - : undefined, - }, - }; - return mappedFilter; - }); + const mappedFilters = (response.value || []).map( + (filter: { + id: string; + conditions: { senderContains: string[] }; + actions: { applyCategories: string[]; moveToFolder: string }; + }) => { + const mappedFilter = { + id: filter.id || "", + criteria: { + from: filter.conditions?.senderContains?.[0] || undefined, + }, + action: { + addLabelIds: filter.actions?.applyCategories || undefined, + removeLabelIds: filter.actions?.moveToFolder + ? ["INBOX"] + : undefined, + }, + }; + return mappedFilter; + }, + ); return mappedFilters; } catch (error) { @@ -1271,8 +1336,11 @@ export class OutlookProvider implements EmailProvider { function getFilter() { const filters: string[] = []; - // Add folder filter based on type - if (query?.type === "all") { + // Add folder filter based on type or labelId + if (query?.labelId) { + // Use labelId as parentFolderId (should be lowercase for Outlook) + filters.push(`parentFolderId eq '${query.labelId.toLowerCase()}'`); + } else if (query?.type === "all") { // For "all" type, include both inbox and archive filters.push( "(parentFolderId eq 'inbox' or parentFolderId eq 'archive')", @@ -1333,27 +1401,50 @@ export class OutlookProvider implements EmailProvider { let sortedMessages = response.value; if (query?.fromEmail) { sortedMessages = response.value.sort( - (a: any, b: any) => + (a: { receivedDateTime: string }, b: { receivedDateTime: string }) => new Date(b.receivedDateTime).getTime() - new Date(a.receivedDateTime).getTime(), ); } // Group messages by conversationId to create threads - const messagesByThread = new Map(); - sortedMessages.forEach((message: any) => { - // Skip messages without conversationId - if (!message.conversationId) { - logger.warn("Message missing conversationId", { - messageId: message.id, - }); - return; - } - - const messages = messagesByThread.get(message.conversationId) || []; - messages.push(message); - messagesByThread.set(message.conversationId, messages); - }); + const messagesByThread = new Map< + string, + { + conversationId: string; + id: string; + bodyPreview: string; + body: { content: string }; + from: { emailAddress: { address: string } }; + toRecipients: { emailAddress: { address: string } }[]; + receivedDateTime: string; + subject: string; + }[] + >(); + sortedMessages.forEach( + (message: { + conversationId: string; + id: string; + bodyPreview: string; + body: { content: string }; + from: { emailAddress: { address: string } }; + toRecipients: { emailAddress: { address: string } }[]; + receivedDateTime: string; + subject: string; + }) => { + // Skip messages without conversationId + if (!message.conversationId) { + logger.warn("Message missing conversationId", { + messageId: message.id, + }); + return; + } + + const messages = messagesByThread.get(message.conversationId) || []; + messages.push(message); + messagesByThread.set(message.conversationId, messages); + }, + ); // Convert to EmailThread format const threads: EmailThread[] = Array.from(messagesByThread.entries()) @@ -1469,6 +1560,55 @@ export class OutlookProvider implements EmailProvider { needsReplyLabelId: needsReplyLabel.id || "", }; } + + async processHistory(options: { + emailAddress: string; + historyId?: number; + startHistoryId?: number; + subscriptionId?: string; + resourceData?: { + id: string; + conversationId?: string; + }; + }): Promise { + if (!options.subscriptionId) { + throw new Error( + "subscriptionId is required for Outlook history processing", + ); + } + + await processOutlookHistory({ + subscriptionId: options.subscriptionId, + resourceData: options.resourceData || { + id: options.historyId?.toString() || "0", + conversationId: options.startHistoryId?.toString(), + }, + }); + } + + async watchEmails(): Promise<{ + expirationDate: Date; + subscriptionId?: string; + } | null> { + const subscription = await watchOutlook(this.client.getClient()); + + if (subscription.expirationDateTime) { + const expirationDate = new Date(subscription.expirationDateTime); + return { + expirationDate, + subscriptionId: subscription.id, + }; + } + return null; + } + + async unwatchEmails(subscriptionId?: string): Promise { + if (!subscriptionId) { + logger.warn("No subscription ID provided for Outlook unwatch"); + return; + } + await unwatchOutlook(this.client.getClient(), subscriptionId); + } } export async function createEmailProvider({ diff --git a/apps/web/utils/encryption.test.ts b/apps/web/utils/encryption.test.ts index e3ceb2f5a2..663f21de4c 100644 --- a/apps/web/utils/encryption.test.ts +++ b/apps/web/utils/encryption.test.ts @@ -16,8 +16,8 @@ vi.mock("@/utils/logger", () => ({ vi.mock("@/env", () => ({ env: { NODE_ENV: "test", - GOOGLE_ENCRYPT_SECRET: "test-secret-key-for-encryption-testing", - GOOGLE_ENCRYPT_SALT: "test-salt-for-encryption", + EMAIL_ENCRYPT_SECRET: "test-secret-key-for-encryption-testing", + EMAIL_ENCRYPT_SALT: "test-salt-for-encryption", }, })); diff --git a/apps/web/utils/encryption.ts b/apps/web/utils/encryption.ts index 6667282723..ca00e6d209 100644 --- a/apps/web/utils/encryption.ts +++ b/apps/web/utils/encryption.ts @@ -17,8 +17,8 @@ const KEY_LENGTH = 32; // 32 bytes for AES-256 // Derive encryption key from environment variables const key = scryptSync( - env.GOOGLE_ENCRYPT_SECRET, - env.GOOGLE_ENCRYPT_SALT, + env.EMAIL_ENCRYPT_SECRET, + env.EMAIL_ENCRYPT_SALT, KEY_LENGTH, ); diff --git a/apps/web/utils/middleware.ts b/apps/web/utils/middleware.ts index 8e7eee6dd2..c0745c1162 100644 --- a/apps/web/utils/middleware.ts +++ b/apps/web/utils/middleware.ts @@ -10,6 +10,11 @@ import { EMAIL_ACCOUNT_HEADER, NO_REFRESH_TOKEN_ERROR_CODE, } from "@/utils/config"; +import prisma from "@/utils/prisma"; +import { + createEmailProvider, + type EmailProvider, +} from "@/utils/email/provider"; const logger = createScopedLogger("middleware"); @@ -32,6 +37,9 @@ export interface RequestWithEmailAccount extends NextRequest { email: string; }; } +export interface RequestWithEmailProvider extends RequestWithEmailAccount { + emailProvider: EmailProvider; +} // Higher-order middleware factory that handles common error logic function withMiddleware( @@ -178,6 +186,60 @@ async function emailAccountMiddleware( return emailAccountReq; } +async function emailProviderMiddleware( + req: NextRequest, +): Promise { + // First run email account middleware + const emailAccountReq = await emailAccountMiddleware(req); + if (emailAccountReq instanceof Response) return emailAccountReq; + + const { userId, emailAccountId } = emailAccountReq.auth; + + try { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { + id: emailAccountId, + userId, // ensure it belongs to the user + }, + include: { + account: { + select: { + provider: true, + }, + }, + }, + }); + + if (!emailAccount) { + return NextResponse.json( + { error: "Email account not found", isKnownError: true }, + { status: 404 }, + ); + } + + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: emailAccount.account.provider, + }); + + const providerReq = emailAccountReq.clone() as RequestWithEmailProvider; + providerReq.auth = emailAccountReq.auth; + providerReq.emailProvider = provider; + + return providerReq; + } catch (error) { + logger.error("Failed to create email provider", { + error, + emailAccountId, + userId, + }); + return NextResponse.json( + { error: "Failed to initialize email provider", isKnownError: true }, + { status: 500 }, + ); + } +} + // Public middlewares that build on the common infrastructure export function withError(handler: NextHandler): NextHandler { return withMiddleware(handler); @@ -193,6 +255,12 @@ export function withEmailAccount( return withMiddleware(handler, emailAccountMiddleware); } +export function withEmailProvider( + handler: NextHandler, +): NextHandler { + return withMiddleware(handler, emailProviderMiddleware); +} + function isErrorWithConfigAndHeaders( error: unknown, ): error is { config: { headers: unknown } } { diff --git a/apps/web/utils/outlook/client.ts b/apps/web/utils/outlook/client.ts index 0e236c987b..73b65bc2f4 100644 --- a/apps/web/utils/outlook/client.ts +++ b/apps/web/utils/outlook/client.ts @@ -93,6 +93,10 @@ export const getOutlookClientWithRefresh = async ({ // Refresh token try { + if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) { + throw new Error("Microsoft login not enabled - missing credentials"); + } + const response = await fetch( "https://login.microsoftonline.com/common/oauth2/v2.0/token", { @@ -101,8 +105,8 @@ export const getOutlookClientWithRefresh = async ({ "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ - client_id: env.MICROSOFT_CLIENT_ID!, - client_secret: env.MICROSOFT_CLIENT_SECRET!, + client_id: env.MICROSOFT_CLIENT_ID, + client_secret: env.MICROSOFT_CLIENT_SECRET, refresh_token: refreshToken, grant_type: "refresh_token", scope: SCOPES.join(" "), @@ -148,10 +152,14 @@ export const getAccessTokenFromClient = (client: OutlookClient): string => { // Helper function to get the OAuth2 URL for linking accounts export function getLinkingOAuth2Url() { + if (!env.MICROSOFT_CLIENT_ID) { + throw new Error("Microsoft login not enabled - missing client ID"); + } + const baseUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; const params = new URLSearchParams({ - client_id: env.MICROSOFT_CLIENT_ID!, + client_id: env.MICROSOFT_CLIENT_ID, response_type: "code", redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/linking/callback`, scope: SCOPES.join(" "), diff --git a/apps/web/utils/outlook/filter.ts b/apps/web/utils/outlook/filter.ts index d8286eebb6..ad9503cc96 100644 --- a/apps/web/utils/outlook/filter.ts +++ b/apps/web/utils/outlook/filter.ts @@ -153,7 +153,7 @@ export async function createCategoryFilter({ .get(); let category = categories.value.find( - (cat: any) => cat.displayName === categoryName, + (cat: { displayName: string }) => cat.displayName === categoryName, ); if (!category) { diff --git a/apps/web/utils/outlook/label.ts b/apps/web/utils/outlook/label.ts index e0a36d7c86..b60ee94f69 100644 --- a/apps/web/utils/outlook/label.ts +++ b/apps/web/utils/outlook/label.ts @@ -320,7 +320,8 @@ export async function archiveThread({ // Filter messages by conversationId manually const threadMessages = messages.value.filter( - (message: any) => message.conversationId === threadId, + (message: { conversationId: string }) => + message.conversationId === threadId, ); if (threadMessages.length > 0) { @@ -426,7 +427,8 @@ export async function markReadThread({ // Filter messages by conversationId manually const threadMessages = messages.value.filter( - (message: any) => message.conversationId === threadId, + (message: { conversationId: string }) => + message.conversationId === threadId, ); if (threadMessages.length > 0) { diff --git a/apps/web/utils/outlook/spam.ts b/apps/web/utils/outlook/spam.ts index 8eca4fba34..4bab180378 100644 --- a/apps/web/utils/outlook/spam.ts +++ b/apps/web/utils/outlook/spam.ts @@ -53,7 +53,8 @@ export async function markSpam(client: OutlookClient, threadId: string) { // Filter messages by conversationId manually const threadMessages = messages.value.filter( - (message: any) => message.conversationId === threadId, + (message: { conversationId: string }) => + message.conversationId === threadId, ); if (threadMessages.length > 0) { diff --git a/apps/web/utils/outlook/trash.ts b/apps/web/utils/outlook/trash.ts index 1682c194cb..83910c958c 100644 --- a/apps/web/utils/outlook/trash.ts +++ b/apps/web/utils/outlook/trash.ts @@ -102,7 +102,8 @@ export async function trashThread(options: { // Filter messages by conversationId manually const threadMessages = messages.value.filter( - (message: any) => message.conversationId === threadId, + (message: { conversationId: string }) => + message.conversationId === threadId, ); if (threadMessages.length > 0) { diff --git a/apps/web/utils/url.ts b/apps/web/utils/url.ts index e969ab3346..6f4b12ecbe 100644 --- a/apps/web/utils/url.ts +++ b/apps/web/utils/url.ts @@ -6,20 +6,67 @@ function getOutlookBaseUrl() { return "https://outlook.live.com/mail/0"; } +const PROVIDER_CONFIG: Record< + string, + { + buildUrl: ( + messageOrThreadId: string, + emailAddress?: string | null, + ) => string; + selectId: (messageId: string, threadId: string) => string; + } +> = { + "microsoft-entra-id": { + buildUrl: (messageOrThreadId: string, emailAddress?: string | null) => { + // Outlook URL format: https://outlook.live.com/mail/0/inbox/id/ENCODED_MESSAGE_ID + // The message ID needs to be URL-encoded for Outlook + const encodedMessageId = encodeURIComponent(messageOrThreadId); + return `${getOutlookBaseUrl()}/inbox/id/${encodedMessageId}`; + }, + selectId: (messageId: string, threadId: string) => threadId, + }, + google: { + buildUrl: (messageOrThreadId: string, emailAddress?: string | null) => + `${getGmailBaseUrl(emailAddress)}/#all/${messageOrThreadId}`, + selectId: (messageId: string, threadId: string) => messageId, + }, + default: { + buildUrl: (messageOrThreadId: string, emailAddress?: string | null) => + `${getGmailBaseUrl(emailAddress)}/#all/${messageOrThreadId}`, + selectId: (messageId: string, threadId: string) => threadId, + }, +} as const; + +function getProviderConfig( + provider?: string, +): (typeof PROVIDER_CONFIG)[keyof typeof PROVIDER_CONFIG] { + return PROVIDER_CONFIG[provider ?? "default"]; +} + export function getEmailUrl( messageOrThreadId: string, emailAddress?: string | null, provider?: string, +): string { + const config = getProviderConfig(provider); + return config.buildUrl(messageOrThreadId, emailAddress); +} + +/** + * Get the appropriate email URL based on provider and available IDs. + * For Google, uses messageId if available, otherwise threadId. + * For other providers, uses threadId. + */ +export function getEmailUrlForMessage( + messageId: string, + threadId: string, + emailAddress?: string | null, + provider?: string, ) { - if (provider === "microsoft-entra-id") { - // Outlook URL format: https://outlook.live.com/mail/0/inbox/id/ENCODED_MESSAGE_ID - // The message ID needs to be URL-encoded for Outlook - const encodedMessageId = encodeURIComponent(messageOrThreadId); - return `${getOutlookBaseUrl()}/inbox/id/${encodedMessageId}`; - } + const config = getProviderConfig(provider); + const idToUse = config.selectId(messageId, threadId); - // Default to Gmail format - return `${getGmailBaseUrl(emailAddress)}/#all/${messageOrThreadId}`; + return getEmailUrl(idToUse, emailAddress, provider); } // Keep the old function name for backward compatibility diff --git a/apps/web/utils/user/delete.ts b/apps/web/utils/user/delete.ts index ff4a0e1297..293ef52ca5 100644 --- a/apps/web/utils/user/delete.ts +++ b/apps/web/utils/user/delete.ts @@ -5,7 +5,11 @@ import { deleteInboxZeroLabels, deleteUserLabels } from "@/utils/redis/label"; import { deleteTinybirdAiCalls } from "@inboxzero/tinybird-ai-analytics"; import { deletePosthogUser, trackUserDeleted } from "@/utils/posthog"; import { captureException } from "@/utils/error"; -import { unwatchEmails } from "@/app/api/google/watch/controller"; +import { unwatchEmails } from "@/app/api/watch/controller"; +import { + createEmailProvider, + type EmailProvider, +} from "@/utils/email/provider"; import { createScopedLogger } from "@/utils/logger"; import { sleep } from "@/utils/sleep"; @@ -15,22 +19,37 @@ export async function deleteUser({ userId }: { userId: string }) { const accounts = await prisma.account.findMany({ where: { userId }, select: { + provider: true, access_token: true, refresh_token: true, expires_at: true, - emailAccount: { select: { id: true, email: true } }, + emailAccount: { + select: { + id: true, + email: true, + watchEmailsSubscriptionId: true, + }, + }, }, }); - const resourcesPromise = accounts.map((account) => { + const resourcesPromise = accounts.map(async (account) => { if (!account.emailAccount) return Promise.resolve(); + + // Create email provider for unwatching + const emailProvider = account.access_token + ? await createEmailProvider({ + emailAccountId: account.emailAccount.id, + provider: account.provider, + }) + : null; + return deleteResources({ emailAccountId: account.emailAccount.id, email: account.emailAccount.email, userId, - accessToken: account.access_token, - refreshToken: account.refresh_token, - expiresAt: account.expires_at, + emailProvider, + subscriptionId: account.emailAccount.watchEmailsSubscriptionId, }); }); @@ -77,16 +96,14 @@ async function deleteResources({ emailAccountId, email, userId, - accessToken, - refreshToken, - expiresAt, + emailProvider, + subscriptionId, }: { emailAccountId: string; email: string; userId: string; - accessToken: string | null; - refreshToken: string | null; - expiresAt: number | null; + emailProvider: EmailProvider | null; + subscriptionId: string | null; }) { const resourcesPromise = Promise.allSettled([ deleteUserLabels({ emailAccountId }), @@ -94,12 +111,11 @@ async function deleteResources({ deleteLoopsContact(emailAccountId), deletePosthogUser({ email }), deleteResendContact({ email }), - accessToken + emailProvider ? unwatchEmails({ emailAccountId, - accessToken, - refreshToken, - expiresAt, + provider: emailProvider, + subscriptionId, }) : Promise.resolve(), ]); diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index b4ecccc233..2e730cc302 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -39,8 +39,8 @@ ENV NEXTAUTH_SECRET="dummy_secret_for_build_only" ENV NEXTAUTH_URL="http://localhost:3000" ENV GOOGLE_CLIENT_ID="dummy_id_for_build_only" ENV GOOGLE_CLIENT_SECRET="dummy_secret_for_build_only" -ENV GOOGLE_ENCRYPT_SECRET="dummy_encrypt_secret_for_build_only" -ENV GOOGLE_ENCRYPT_SALT="dummy_encrypt_salt_for_build_only" +ENV EMAIL_ENCRYPT_SECRET="dummy_encrypt_secret_for_build_only" +ENV EMAIL_ENCRYPT_SALT="dummy_encrypt_salt_for_build_only" ENV GOOGLE_PUBSUB_TOPIC_NAME="dummy_topic_for_build_only" ENV GOOGLE_PUBSUB_VERIFICATION_TOKEN="dummy_pubsub_token_for_build" ENV INTERNAL_API_KEY="dummy_apikey_for_build_only" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fe484e8cd..cac009ca15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,9 +176,6 @@ importers: '@microsoft/microsoft-graph-client': specifier: 3.0.7 version: 3.0.7 - '@microsoft/microsoft-graph-types': - specifier: 2.40.0 - version: 2.40.0 '@mux/mux-player-react': specifier: 3.4.0 version: 3.4.0(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -513,6 +510,9 @@ importers: '@inboxzero/eslint-config': specifier: workspace:* version: link:../../packages/eslint-config + '@microsoft/microsoft-graph-types': + specifier: ^2.40.0 + version: 2.40.0 '@testing-library/react': specifier: 16.3.0 version: 16.3.0(@testing-library/dom@10.2.0)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) diff --git a/turbo.json b/turbo.json index f5c0a5eaff..05d0838735 100644 --- a/turbo.json +++ b/turbo.json @@ -14,13 +14,11 @@ "GOOGLE_CLIENT_SECRET", "GOOGLE_PUBSUB_TOPIC_NAME", "GOOGLE_PUBSUB_VERIFICATION_TOKEN", - "GOOGLE_ENCRYPT_SECRET", - "GOOGLE_ENCRYPT_SALT", + "EMAIL_ENCRYPT_SECRET", + "EMAIL_ENCRYPT_SALT", "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", - "MICROSOFT_ENCRYPT_SECRET", - "MICROSOFT_ENCRYPT_SALT", "DEFAULT_LLM_PROVIDER", "DEFAULT_LLM_MODEL", @@ -50,7 +48,7 @@ "LOG_ZOD_ERRORS", "ENABLE_DEBUG_LOGS", - + "LEMON_SQUEEZY_SIGNING_SECRET", "LEMON_SQUEEZY_API_KEY",