From d4e072d3f5eb657f9d1dc390ee058f6ea1f7248b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 01:16:00 +0300 Subject: [PATCH 001/176] move over relations to emailaccount --- apps/web/prisma/schema.prisma | 187 ++++++++++++------------- apps/web/utils/reply-tracker/enable.ts | 24 ++-- apps/web/utils/rule/prompt-file.ts | 4 +- apps/web/utils/user/delete.ts | 12 +- 4 files changed, 111 insertions(+), 116 deletions(-) diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 4e5668032..f0df7fed9 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -87,21 +87,11 @@ model User { premiumAdminId String? premiumAdmin Premium? @relation(fields: [premiumAdminId], references: [id]) - labels Label[] - rules Rule[] - executedRules ExecutedRule[] - newsletters Newsletter[] - coldEmails ColdEmail[] - groups Group[] apiKeys ApiKey[] - categories Category[] - threadTrackers ThreadTracker[] unsubscribeTokens EmailToken[] - cleanupJobs CleanupJob[] - cleanupThreads CleanupThread[] - emailMessages EmailMessage[] knowledges Knowledge[] - emailAccounts EmailAccount[] + + emailAccounts EmailAccount[] @@index([lastSummaryEmailAt]) } @@ -132,16 +122,22 @@ model EmailAccount { outboundReplyTracking Boolean @default(false) autoCategorizeSenders Boolean @default(false) - // To add at a later date: - // signature String? - // ... - userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) accountId String @unique account Account @relation(fields: [accountId], references: [id]) - cleanupJobs CleanupJob[] + labels Label[] + rules Rule[] + executedRules ExecutedRule[] + newsletters Newsletter[] + coldEmails ColdEmail[] + groups Group[] + categories Category[] + threadTrackers ThreadTracker[] + cleanupJobs CleanupJob[] + cleanupThreads CleanupThread[] + emailMessages EmailMessage[] @@index([lastSummaryEmailAt]) } @@ -199,31 +195,32 @@ model VerificationToken { } model Label { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - gmailLabelId String - name String - description String? // used in prompts - enabled Boolean @default(true) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + gmailLabelId String + name String + description String? // used in prompts + enabled Boolean @default(true) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) - @@unique([gmailLabelId, userId]) - @@unique([name, userId]) + @@unique([gmailLabelId, emailAccountId]) + @@unique([name, emailAccountId]) } model Rule { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - name String - actions Action[] - enabled Boolean @default(true) - automate Boolean @default(false) // if disabled, user must approve to execute - runOnThreads Boolean @default(false) // if disabled, only runs on individual emails - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + actions Action[] + enabled Boolean @default(true) + automate Boolean @default(false) // if disabled, user must approve to execute + runOnThreads Boolean @default(false) // if disabled, only runs on individual emails + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + executedRules ExecutedRule[] // conditions: ai, group, static, category @@ -250,8 +247,8 @@ model Rule { systemType SystemType? - @@unique([name, userId]) - @@unique([userId, systemType]) + @@unique([name, emailAccountId]) + @@unique([emailAccountId, systemType]) } model Action { @@ -288,14 +285,13 @@ model ExecutedRule { ruleId String? rule Rule? @relation(fields: [ruleId], references: [id]) - // storing user here in case rule was deleted - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) actionItems ExecutedAction[] - @@unique([userId, threadId, messageId], name: "unique_user_thread_message") - @@index([userId, status, createdAt]) + @@unique([emailAccountId, threadId, messageId], name: "unique_emailAccount_thread_message") + @@index([emailAccountId, status, createdAt]) } model ExecutedAction { @@ -328,17 +324,17 @@ model ExecutedAction { // "Name" is no longer in use although still required. // If we really wanted we could remove Group and just have a relation between Rule and GroupItem, but leaving as is for now. model Group { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - name String - prompt String? - items GroupItem[] - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - rule Rule? + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + prompt String? + items GroupItem[] + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + rule Rule? - @@unique([name, userId]) + @@unique([name, emailAccountId]) } model GroupItem { @@ -354,17 +350,18 @@ model GroupItem { } model Category { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - name String - description String? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + description String? + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailSenders Newsletter[] rules Rule[] - @@unique([name, userId]) + @@unique([name, emailAccountId]) } // Represents a sender (`email`) that a user can unsubscribe from, @@ -381,13 +378,14 @@ model Newsletter { patternAnalyzed Boolean @default(false) lastAnalyzedAt DateTime? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + categoryId String? category Category? @relation(fields: [categoryId], references: [id]) - @@unique([email, userId]) - @@index([userId, status]) + @@unique([email, emailAccountId]) + @@index([emailAccountId, status]) } model ColdEmail { @@ -400,12 +398,12 @@ model ColdEmail { status ColdEmailStatus? reason String? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) - @@unique([userId, fromEmail]) - @@index([userId, status]) - @@index([userId, createdAt]) + @@unique([emailAccountId, fromEmail]) + @@index([emailAccountId, status]) + @@index([emailAccountId, createdAt]) } model EmailMessage { @@ -424,13 +422,13 @@ model EmailMessage { draft Boolean inbox Boolean - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) - @@unique([userId, threadId, messageId]) - @@index([userId, threadId]) - @@index([userId, date]) - @@index([userId, from]) + @@unique([emailAccountId, threadId, messageId]) + @@index([emailAccountId, threadId]) + @@index([emailAccountId, date]) + @@index([emailAccountId, from]) } model ThreadTracker { @@ -443,13 +441,13 @@ model ThreadTracker { resolved Boolean @default(false) type ThreadTrackerType - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) - @@unique([userId, threadId, messageId]) - @@index([userId, resolved]) - @@index([userId, resolved, sentAt, type]) - @@index([userId, type, resolved, sentAt]) + @@unique([emailAccountId, threadId, messageId]) + @@index([emailAccountId, resolved]) + @@index([emailAccountId, resolved, sentAt, type]) + @@index([emailAccountId, type, resolved, sentAt]) } model CleanupJob { @@ -465,24 +463,21 @@ model CleanupJob { skipReceipt Boolean? skipAttachment Boolean? skipConversation Boolean? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) email String emailAccount EmailAccount @relation(fields: [email], references: [email], onDelete: Cascade) threads CleanupThread[] } model CleanupThread { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - threadId String - archived Boolean // this can also mean "mark as read". depends on CleanupJob.action - // labelIds String[] - jobId String - job CleanupJob @relation(fields: [jobId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + threadId String + archived Boolean // this can also mean "mark as read". depends on CleanupJob.action + jobId String + job CleanupJob @relation(fields: [jobId], references: [id], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) } model Knowledge { diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index 3a41de848..8f8fe1413 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -22,22 +22,18 @@ export async function enableReplyTracker({ email }: { email: string }) { aiApiKey: true, about: true, rulesPrompt: true, - user: { + rules: { + where: { + systemType: SystemType.TO_REPLY, + }, select: { - rules: { - where: { - systemType: SystemType.TO_REPLY, - }, + id: true, + systemType: true, + actions: { select: { id: true, - systemType: true, - actions: { - select: { - id: true, - type: true, - label: true, - }, - }, + type: true, + label: true, }, }, }, @@ -48,7 +44,7 @@ export async function enableReplyTracker({ email }: { email: string }) { // If enabled already skip if (!emailAccount) return { error: "Email account not found" }; - const rule = emailAccount.user.rules.find( + const rule = emailAccount.rules.find( (r) => r.systemType === SystemType.TO_REPLY, ); diff --git a/apps/web/utils/rule/prompt-file.ts b/apps/web/utils/rule/prompt-file.ts index b5f8654c3..71c6b976f 100644 --- a/apps/web/utils/rule/prompt-file.ts +++ b/apps/web/utils/rule/prompt-file.ts @@ -53,20 +53,18 @@ export async function updatePromptFileOnRuleUpdated({ } export async function updateRuleInstructionsAndPromptFile({ - userId, email, ruleId, instructions, currentRule, }: { - userId: string; email: string; ruleId: string; instructions: string; currentRule: RuleWithRelations | null; }) { const updatedRule = await prisma.rule.update({ - where: { id: ruleId, userId }, + where: { id: ruleId, emailAccountId: email }, data: { instructions }, include: { actions: true, categoryFilters: true, group: true }, }); diff --git a/apps/web/utils/user/delete.ts b/apps/web/utils/user/delete.ts index 17ba6fa35..3f1a5423e 100644 --- a/apps/web/utils/user/delete.ts +++ b/apps/web/utils/user/delete.ts @@ -49,7 +49,7 @@ export async function deleteUser({ // First delete ExecutedRules and their associated ExecutedActions in batches // If we try do this in one go for a user with a lot of executed rules, this will fail logger.info("Deleting ExecutedRules in batches"); - await deleteExecutedRulesInBatches(userId); + await deleteExecutedRulesInBatches({ email }); logger.info("Deleting user"); await prisma.user.delete({ where: { email } }); } catch (error) { @@ -100,13 +100,19 @@ export async function deleteUser({ /** * Delete ExecutedRules and their associated ExecutedActions in batches */ -async function deleteExecutedRulesInBatches(userId: string, batchSize = 100) { +async function deleteExecutedRulesInBatches({ + email, + batchSize = 100, +}: { + email: string; + batchSize?: number; +}) { let deletedTotal = 0; while (true) { // 1. Get a batch of ExecutedRule IDs const executedRules = await prisma.executedRule.findMany({ - where: { userId }, + where: { emailAccountId: email }, select: { id: true }, take: batchSize, }); From fbd844ad08d8fc85d8ce38c8207dfe0b86b64617 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 01:35:42 +0300 Subject: [PATCH 002/176] more fixes --- .../app/(app)/smart-categories/setup/page.tsx | 2 +- apps/web/app/api/user/categories/route.ts | 10 +-- .../categorize/senders/batch/handle-batch.ts | 4 +- apps/web/app/api/user/rules/[id]/route.ts | 8 +- apps/web/app/api/user/rules/route.ts | 10 +-- apps/web/utils/actions/ai-rule.ts | 81 ++++++++++--------- apps/web/utils/actions/categorize.ts | 38 ++++----- apps/web/utils/actions/clean.ts | 5 +- .../ai/assistant/process-user-request.ts | 14 ++-- .../utils/categorize/senders/categorize.ts | 29 ++++--- apps/web/utils/category.server.ts | 23 +++--- apps/web/utils/reply-tracker/enable.ts | 2 +- apps/web/utils/rule/rule.ts | 81 +++++++++++-------- apps/web/utils/sender.ts | 6 +- 14 files changed, 171 insertions(+), 142 deletions(-) diff --git a/apps/web/app/(app)/smart-categories/setup/page.tsx b/apps/web/app/(app)/smart-categories/setup/page.tsx index e6f7f9146..929b0cad7 100644 --- a/apps/web/app/(app)/smart-categories/setup/page.tsx +++ b/apps/web/app/(app)/smart-categories/setup/page.tsx @@ -9,7 +9,7 @@ export default async function SetupCategoriesPage() { const email = session?.user.email; if (!email) throw new Error("Not authenticated"); - const categories = await getUserCategories(session.user.id); + const categories = await getUserCategories({ email }); return ( <> diff --git a/apps/web/app/api/user/categories/route.ts b/apps/web/app/api/user/categories/route.ts index e232c7347..35a5a9f37 100644 --- a/apps/web/app/api/user/categories/route.ts +++ b/apps/web/app/api/user/categories/route.ts @@ -5,17 +5,17 @@ import { getUserCategories } from "@/utils/category.server"; export type UserCategoriesResponse = Awaited>; -async function getCategories({ userId }: { userId: string }) { - const result = await getUserCategories(userId); +async function getCategories({ email }: { email: string }) { + const result = await getUserCategories({ email }); return { result }; } export const GET = withError(async () => { const session = await auth(); - if (!session?.user.id) - return NextResponse.json({ error: "Not authenticated" }); + if (!session?.user.email) + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - const result = await getCategories({ userId: session.user.id }); + const result = await getCategories({ email: session.user.email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts index c3fbba58f..e07ffa7a0 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts @@ -44,7 +44,7 @@ async function handleBatchInternal(request: Request) { if (isActionError(userResult)) return userResult; const { emailAccount } = userResult; - const categoriesResult = await getCategories(emailAccount.userId); + const categoriesResult = await getCategories({ email }); if (isActionError(categoriesResult)) return categoriesResult; const { categories } = categoriesResult; @@ -105,7 +105,7 @@ async function handleBatchInternal(request: Request) { sender: result.sender, categories, categoryName: result.category ?? UNKNOWN_CATEGORY, - userId: emailAccount.userId, + userEmail: emailAccount.email, }); } diff --git a/apps/web/app/api/user/rules/[id]/route.ts b/apps/web/app/api/user/rules/[id]/route.ts index ea8dc161b..5a5c2c56d 100644 --- a/apps/web/app/api/user/rules/[id]/route.ts +++ b/apps/web/app/api/user/rules/[id]/route.ts @@ -6,8 +6,10 @@ import { withError } from "@/utils/middleware"; export type RuleResponse = Awaited>; -async function getRule({ ruleId, userId }: { ruleId: string; userId: string }) { - const rule = await prisma.rule.findUnique({ where: { id: ruleId, userId } }); +async function getRule({ ruleId, email }: { ruleId: string; email: string }) { + const rule = await prisma.rule.findUnique({ + where: { id: ruleId, emailAccountId: email }, + }); return { rule }; } @@ -21,7 +23,7 @@ export const GET = withError(async (_request, { params }) => { const result = await getRule({ ruleId: id, - userId: session.user.id, + email: session.user.email, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/rules/route.ts b/apps/web/app/api/user/rules/route.ts index 20475d9ae..d02969300 100644 --- a/apps/web/app/api/user/rules/route.ts +++ b/apps/web/app/api/user/rules/route.ts @@ -5,9 +5,9 @@ import prisma from "@/utils/prisma"; export type RulesResponse = Awaited>; -async function getRules({ userId }: { userId: string }) { +async function getRules({ email }: { email: string }) { return await prisma.rule.findMany({ - where: { userId }, + where: { emailAccountId: email }, include: { actions: true, group: { select: { name: true } }, @@ -19,10 +19,10 @@ async function getRules({ userId }: { userId: string }) { export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const result = await getRules({ userId: session.user.id }); + const result = await getRules({ email }); return NextResponse.json(result); }); diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index ae54db0b1..4bdd073e5 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -67,8 +67,8 @@ export const runRulesAction = withActionInstrumentation( fetchExecutedRule ? prisma.executedRule.findUnique({ where: { - unique_user_thread_message: { - userId: emailAccount.userId, + unique_emailAccount_thread_message: { + emailAccountId: emailAccount.email, threadId, messageId, }, @@ -105,7 +105,7 @@ export const runRulesAction = withActionInstrumentation( isTest, gmail, message, - rules: emailAccount.user.rules, + rules: emailAccount.rules, user: emailAccount, }); @@ -148,7 +148,7 @@ export const testAiCustomContentAction = withActionInstrumentation( inline: [], internalDate: new Date().toISOString(), }, - rules: emailAccount.user.rules, + rules: emailAccount.rules, user: emailAccount, }); @@ -182,7 +182,7 @@ export const createAutomationAction = withActionInstrumentation< return await safeCreateRule({ result, - userId: emailAccount.userId, + email: emailAccount.email, }); }); @@ -196,10 +196,11 @@ export const setRuleRunOnThreadsAction = withActionInstrumentation( runOnThreads: boolean; }) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; await prisma.rule.update({ - where: { id: ruleId, userId: session.user.id }, + where: { id: ruleId, emailAccountId: email }, data: { runOnThreads }, }); }, @@ -238,10 +239,11 @@ export const rejectPlanAction = withActionInstrumentation( "rejectPlan", async ({ executedRuleId }: { executedRuleId: string }) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; await prisma.executedRule.updateMany({ - where: { id: executedRuleId, userId: session.user.id }, + where: { id: executedRuleId, emailAccountId: email }, data: { status: ExecutedRuleStatus.REJECTED }, }); }, @@ -293,11 +295,7 @@ export const saveRulesPromptAction = withActionInstrumentation( email: true, userId: true, about: true, - user: { - select: { - categories: { select: { id: true, name: true } }, - }, - }, + categories: { select: { id: true, name: true } }, }, }); @@ -352,7 +350,7 @@ export const saveRulesPromptAction = withActionInstrumentation( user: emailAccount, promptFile: diff.addedRules.join("\n\n"), isEditing: false, - availableCategories: emailAccount.user.categories.map((c) => c.name), + availableCategories: emailAccount.categories.map((c) => c.name), }); logger.info("Added rules", { email, @@ -362,7 +360,7 @@ export const saveRulesPromptAction = withActionInstrumentation( // find existing rules const userRules = await prisma.rule.findMany({ - where: { userId: session.user.id, enabled: true }, + where: { emailAccountId: email, enabled: true }, include: { actions: true }, }); logger.info("Found existing user rules", { @@ -389,7 +387,7 @@ export const saveRulesPromptAction = withActionInstrumentation( } const executedRule = await prisma.executedRule.findFirst({ - where: { userId: session.user.id, ruleId: rule.rule.id }, + where: { emailAccountId: email, ruleId: rule.rule.id }, }); logger.info("Removing rule", { @@ -401,14 +399,14 @@ export const saveRulesPromptAction = withActionInstrumentation( if (executedRule) { await prisma.rule.update({ - where: { id: rule.rule.id, userId: session.user.id }, + where: { id: rule.rule.id, emailAccountId: email }, data: { enabled: false }, }); } else { try { await deleteRule({ ruleId: rule.rule.id, - userId: session.user.id, + email, groupId: rule.rule.groupId, }); } catch (error) { @@ -435,7 +433,7 @@ export const saveRulesPromptAction = withActionInstrumentation( ) .join("\n\n"), isEditing: true, - availableCategories: emailAccount.user.categories.map((c) => c.name), + availableCategories: emailAccount.categories.map((c) => c.name), }); for (const rule of editedRules) { @@ -453,14 +451,19 @@ export const saveRulesPromptAction = withActionInstrumentation( ruleId: rule.ruleId, }); - const categoryIds = await getUserCategoriesForNames( - session.user.id, - rule.condition.categories?.categoryFilters || [], - ); + const categoryIds = await getUserCategoriesForNames({ + email, + names: rule.condition.categories?.categoryFilters || [], + }); editRulesCount++; - await safeUpdateRule(rule.ruleId, rule, session.user.id, categoryIds); + await safeUpdateRule({ + ruleId: rule.ruleId, + result: rule, + email, + categoryIds, + }); } } } else { @@ -469,7 +472,7 @@ export const saveRulesPromptAction = withActionInstrumentation( user: emailAccount, promptFile: data.rulesPrompt, isEditing: false, - availableCategories: emailAccount.user.categories.map((c) => c.name), + availableCategories: emailAccount.categories.map((c) => c.name), }); logger.info("Rules to be added", { email, @@ -487,7 +490,7 @@ export const saveRulesPromptAction = withActionInstrumentation( await safeCreateRule({ result: rule, - userId: emailAccount.userId, + email, categoryNames: rule.condition.categories?.categoryFilters || [], }); } @@ -597,10 +600,11 @@ export const setRuleEnabledAction = withActionInstrumentation( "setRuleEnabled", async ({ ruleId, enabled }: { ruleId: string; enabled: boolean }) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const emailAccountId = session?.user.email; + if (!emailAccountId) return { error: "Not logged in" }; await prisma.rule.update({ - where: { id: ruleId, userId: session.user.id }, + where: { id: ruleId, emailAccountId }, data: { enabled }, }); }, @@ -610,7 +614,8 @@ export const reportAiMistakeAction = withActionInstrumentation( "reportAiMistake", async (unsafeBody: ReportAiMistakeBody) => { const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; + const emailAccountId = session?.user.email; + if (!emailAccountId) return { error: "Not logged in" }; const { success, data, error } = reportAiMistakeBody.safeParse(unsafeBody); if (!success) return { error: error.message }; @@ -622,15 +627,15 @@ export const reportAiMistakeAction = withActionInstrumentation( const [expectedRule, actualRule, user] = await Promise.all([ expectedRuleId ? prisma.rule.findUnique({ - where: { id: expectedRuleId, userId: session.user.id }, + where: { id: expectedRuleId, emailAccountId }, }) : null, actualRuleId ? prisma.rule.findUnique({ - where: { id: actualRuleId, userId: session.user.id }, + where: { id: actualRuleId, emailAccountId }, }) : null, - getAiUser({ email: session.user.email }), + getAiUser({ email: emailAccountId }), ]); if (expectedRuleId && !expectedRule) @@ -679,13 +684,9 @@ async function getEmailAccountWithRules({ email }: { email: string }) { aiProvider: true, aiModel: true, aiApiKey: true, - user: { - select: { - rules: { - where: { enabled: true }, - include: { actions: true, categoryFilters: true }, - }, - }, + rules: { + where: { enabled: true }, + include: { actions: true, categoryFilters: true }, }, }, }); diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 39bfc9fdc..70fadeb35 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -59,7 +59,7 @@ export const bulkCategorizeSendersAction = withActionInstrumentation( const existingSenders = await prisma.newsletter.findMany({ where: { email: { in: allSenders }, - userId: user.id, + emailAccountId: user.email, category: { isNot: null }, }, select: { email: true }, @@ -139,15 +139,15 @@ export const changeSenderCategoryAction = withActionInstrumentation( "changeSenderCategory", async ({ sender, categoryId }: { sender: string; categoryId: string }) => { const session = await auth(); - if (!session?.user) return { error: "Not authenticated" }; + if (!session?.user?.email) return { error: "Not authenticated" }; const category = await prisma.category.findUnique({ - where: { id: categoryId, userId: session.user.id }, + where: { id: categoryId, emailAccountId: session.user.email }, }); if (!category) return { error: "Category not found" }; await updateCategoryForSender({ - userId: session.user.id, + userEmail: session.user.email, sender, categoryId, }); @@ -160,7 +160,7 @@ export const upsertDefaultCategoriesAction = withActionInstrumentation( "upsertDefaultCategories", async (categories: { id?: string; name: string; enabled: boolean }[]) => { const session = await auth(); - if (!session?.user) return { error: "Not authenticated" }; + if (!session?.user?.email) return { error: "Not authenticated" }; for (const { id, name, enabled } of categories) { const description = Object.values(defaultCategory).find( @@ -168,9 +168,9 @@ export const upsertDefaultCategoriesAction = withActionInstrumentation( )?.description; if (enabled) { - await upsertCategory(session.user.id, { name, description }); + await upsertCategory(session.user.email, { name, description }); } else { - if (id) await deleteCategory(session.user.id, id); + if (id) await deleteCategory(session.user.email, id); } } @@ -182,12 +182,12 @@ export const createCategoryAction = withActionInstrumentation( "createCategory", async (unsafeData: CreateCategoryBody) => { const session = await auth(); - if (!session?.user) return { error: "Not authenticated" }; + if (!session?.user?.email) return { error: "Not authenticated" }; const { success, data, error } = createCategoryBody.safeParse(unsafeData); if (!success) return { error: error.message }; - await upsertCategory(session.user.id, data); + await upsertCategory(session.user.email, data); revalidatePath("/smart-categories"); }, @@ -197,23 +197,25 @@ export const deleteCategoryAction = withActionInstrumentation( "deleteCategory", async (categoryId: string) => { const session = await auth(); - if (!session?.user) return { error: "Not authenticated" }; + if (!session?.user?.email) return { error: "Not authenticated" }; - await deleteCategory(session.user.id, categoryId); + await deleteCategory(session.user.email, categoryId); revalidatePath("/smart-categories"); }, ); -async function deleteCategory(userId: string, categoryId: string) { - await prisma.category.delete({ where: { id: categoryId, userId } }); +async function deleteCategory(email: string, categoryId: string) { + await prisma.category.delete({ + where: { id: categoryId, emailAccountId: email }, + }); } -async function upsertCategory(userId: string, newCategory: CreateCategoryBody) { +async function upsertCategory(email: string, newCategory: CreateCategoryBody) { try { if (newCategory.id) { const category = await prisma.category.update({ - where: { id: newCategory.id, userId }, + where: { id: newCategory.id, emailAccountId: email }, data: { name: newCategory.name, description: newCategory.description, @@ -224,7 +226,7 @@ async function upsertCategory(userId: string, newCategory: CreateCategoryBody) { } else { const category = await prisma.category.create({ data: { - userId, + emailAccountId: email, name: newCategory.name, description: newCategory.description, }, @@ -260,12 +262,12 @@ export const removeAllFromCategoryAction = withActionInstrumentation( "removeAllFromCategory", async (categoryName: string) => { const session = await auth(); - if (!session?.user) return { error: "Not authenticated" }; + if (!session?.user?.email) return { error: "Not authenticated" }; await prisma.newsletter.updateMany({ where: { category: { name: categoryName }, - userId: session.user.id, + emailAccountId: session.user.email, }, data: { categoryId: null }, }); diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index d0ccf1ee5..68d05cdaa 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -68,7 +68,6 @@ export const cleanInboxAction = withActionInstrumentation( // create a cleanup job const job = await prisma.cleanupJob.create({ data: { - userId: session?.user.id, email, action: data.action, instructions: data.instructions, @@ -245,7 +244,7 @@ export const undoCleanInboxAction = withActionInstrumentation( try { // We need to get the thread first to get the jobId const thread = await prisma.cleanupThread.findFirst({ - where: { userId: session?.user.id, threadId }, + where: { emailAccountId: email, threadId }, orderBy: { createdAt: "desc" }, }); @@ -308,7 +307,7 @@ export const changeKeepToDoneAction = withActionInstrumentation( try { // We need to get the thread first to get the jobId const thread = await prisma.cleanupThread.findFirst({ - where: { userId: session?.user.id, threadId }, + where: { emailAccountId: email, threadId }, orderBy: { createdAt: "desc" }, }); diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 3b235443c..0e7977c0d 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -520,14 +520,14 @@ ${senderCategory || "No category"} condition as CreateRuleSchemaWithCategories["condition"]; try { - const categoryIds = await getUserCategoriesForNames( - user.userId, - conditions.categories?.categoryFilters || [], - ); + const categoryIds = await getUserCategoriesForNames({ + email: user.email, + names: conditions.categories?.categoryFilters || [], + }); const rule = await createRule({ result: { name, condition, actions }, - userId: user.userId, + email: user.email, categoryIds, }); @@ -653,7 +653,7 @@ const getUpdateCategoryTool = ( trackToolCall("update_sender_category", userEmail); const existingSender = await findSenderByEmail({ - userId, + emailAccountId: userEmail, email: sender, }); @@ -669,7 +669,7 @@ const getUpdateCategoryTool = ( try { await updateCategoryForSender({ - userId, + userEmail, sender: existingSender?.email || sender, categoryId: cat.id, }); diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index d60294105..35522094a 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -21,7 +21,8 @@ export async function categorizeSender( accessToken: string, userCategories?: Pick[], ) { - const categories = userCategories || (await getUserCategories(user.userId)); + const categories = + userCategories || (await getUserCategories({ email: user.email })); if (categories.length === 0) return { categoryId: undefined }; const previousEmails = await getThreadsFromSenderWithSubject( @@ -43,7 +44,7 @@ export async function categorizeSender( sender: senderAddress, categories, categoryName: aiResult.category, - userId: user.userId, + userEmail: user.email, }); return { categoryId: newsletter.categoryId }; @@ -58,12 +59,12 @@ export async function categorizeSender( } export async function updateSenderCategory({ - userId, + userEmail, sender, categories, categoryName, }: { - userId: string; + userEmail: string; sender: string; categories: Pick[]; categoryName: string; @@ -76,7 +77,7 @@ export async function updateSenderCategory({ newCategory = await prisma.category.create({ data: { name: categoryName, - userId, + emailAccountId: userEmail, // color: getRandomColor(), }, }); @@ -85,11 +86,13 @@ export async function updateSenderCategory({ // save category const newsletter = await prisma.newsletter.upsert({ - where: { email_userId: { email: sender, userId } }, + where: { + email_emailAccountId: { email: sender, emailAccountId: userEmail }, + }, update: { categoryId: category.id }, create: { email: sender, - userId, + emailAccountId: userEmail, categoryId: category.id, }, }); @@ -101,21 +104,21 @@ export async function updateSenderCategory({ } export async function updateCategoryForSender({ - userId, + userEmail, sender, categoryId, }: { - userId: string; + userEmail: string; sender: string; categoryId: string; }) { const email = extractEmailAddress(sender); await prisma.newsletter.upsert({ - where: { email_userId: { email, userId } }, + where: { email_emailAccountId: { email, emailAccountId: userEmail } }, update: { categoryId }, create: { email, - userId, + emailAccountId: userEmail, categoryId, }, }); @@ -137,8 +140,8 @@ function preCategorizeSendersWithStaticRules( }); } -export async function getCategories(userId: string) { - const categories = await getUserCategories(userId); +export async function getCategories({ email }: { email: string }) { + const categories = await getUserCategories({ email }); if (categories.length === 0) return { error: "No categories found" }; return { categories }; } diff --git a/apps/web/utils/category.server.ts b/apps/web/utils/category.server.ts index 84d1c6f96..c2704d603 100644 --- a/apps/web/utils/category.server.ts +++ b/apps/web/utils/category.server.ts @@ -10,14 +10,16 @@ export type CategoryWithRules = Prisma.CategoryGetPayload<{ }; }>; -export const getUserCategories = async (userId: string) => { - const categories = await prisma.category.findMany({ where: { userId } }); +export const getUserCategories = async ({ email }: { email: string }) => { + const categories = await prisma.category.findMany({ + where: { emailAccountId: email }, + }); return categories; }; -export const getUserCategoriesWithRules = async (userId: string) => { +export const getUserCategoriesWithRules = async (email: string) => { const categories = await prisma.category.findMany({ - where: { userId }, + where: { emailAccountId: email }, select: { id: true, name: true, @@ -28,14 +30,17 @@ export const getUserCategoriesWithRules = async (userId: string) => { return categories; }; -export const getUserCategoriesForNames = async ( - userId: string, - names: string[], -) => { +export const getUserCategoriesForNames = async ({ + email, + names, +}: { + email: string; + names: string[]; +}) => { if (!names.length) return []; const categories = await prisma.category.findMany({ - where: { userId, name: { in: names } }, + where: { emailAccountId: email, name: { in: names } }, select: { id: true }, }); if (categories.length !== names.length) { diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index 8f8fe1413..01223152b 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -97,7 +97,7 @@ export async function enableReplyTracker({ email }: { email: string }) { }, ], }, - userId: emailAccount.userId, + email: emailAccount.email, systemType: SystemType.TO_REPLY, }); diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 492e206f2..1668390bc 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -29,24 +29,24 @@ export function partialUpdateRule({ export async function safeCreateRule({ result, - userId, + email, categoryNames, systemType, }: { result: CreateOrUpdateRuleSchemaWithCategories; - userId: string; + email: string; categoryNames?: string[] | null; systemType?: SystemType | null; }) { - const categoryIds = await getUserCategoriesForNames( - userId, - categoryNames || [], - ); + const categoryIds = await getUserCategoriesForNames({ + email, + names: categoryNames || [], + }); try { const rule = await createRule({ result, - userId, + email, categoryIds, systemType, }); @@ -56,14 +56,14 @@ export async function safeCreateRule({ // if rule name already exists, create a new rule with a unique name const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, - userId, + email, categoryIds, }); return rule; } logger.error("Error creating rule", { - userId, + email, error: error instanceof Error ? { message: error.message, stack: error.stack, name: error.name } @@ -74,28 +74,38 @@ export async function safeCreateRule({ } } -export async function safeUpdateRule( - ruleId: string, - result: CreateOrUpdateRuleSchemaWithCategories, - userId: string, - categoryIds?: string[] | null, -) { +export async function safeUpdateRule({ + ruleId, + result, + email, + categoryIds, +}: { + ruleId: string; + result: CreateOrUpdateRuleSchemaWithCategories; + email: string; + categoryIds?: string[] | null; +}) { try { - const rule = await updateRule(ruleId, result, userId, categoryIds); + const rule = await updateRule({ + ruleId, + result, + emailAccountId: email, + categoryIds, + }); return { id: rule.id }; } catch (error) { if (isDuplicateError(error, "name")) { // if rule name already exists, create a new rule with a unique name const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, - userId, + email, categoryIds, }); return { id: rule.id }; } logger.error("Error updating rule", { - userId, + email, error: error instanceof Error ? { message: error.message, stack: error.stack, name: error.name } @@ -108,12 +118,12 @@ export async function safeUpdateRule( export async function createRule({ result, - userId, + email, categoryIds, systemType, }: { result: CreateOrUpdateRuleSchemaWithCategories; - userId: string; + email: string; categoryIds?: string[] | null; systemType?: SystemType | null; }) { @@ -122,7 +132,7 @@ export async function createRule({ return prisma.rule.create({ data: { name: result.name, - userId, + emailAccountId: email, systemType, actions: { createMany: { data: mappedActions } }, automate: shouldAutomate( @@ -155,17 +165,22 @@ export async function createRule({ }); } -async function updateRule( - ruleId: string, - result: CreateOrUpdateRuleSchemaWithCategories, - userId: string, - categoryIds?: string[] | null, -) { +async function updateRule({ + ruleId, + result, + emailAccountId, + categoryIds, +}: { + ruleId: string; + result: CreateOrUpdateRuleSchemaWithCategories; + emailAccountId: string; + categoryIds?: string[] | null; +}) { return prisma.rule.update({ where: { id: ruleId }, data: { name: result.name, - userId, + emailAccountId, // NOTE: this is safe for now as `Action` doesn't have relations // but if we add relations to `Action`, we would need to `update` here instead of `deleteMany` and `createMany` to avoid cascading deletes actions: { @@ -190,18 +205,20 @@ async function updateRule( } export async function deleteRule({ - userId, + email, ruleId, groupId, }: { + email: string; ruleId: string; - userId: string; groupId?: string | null; }) { return Promise.all([ - prisma.rule.delete({ where: { id: ruleId, userId } }), + prisma.rule.delete({ where: { id: ruleId, emailAccountId: email } }), // in the future, we can make this a cascade delete, but we need to change the schema for this to happen - groupId ? prisma.group.delete({ where: { id: groupId, userId } }) : null, + groupId + ? prisma.group.delete({ where: { id: groupId, emailAccountId: email } }) + : null, ]); } diff --git a/apps/web/utils/sender.ts b/apps/web/utils/sender.ts index 299d8d744..833046277 100644 --- a/apps/web/utils/sender.ts +++ b/apps/web/utils/sender.ts @@ -2,10 +2,10 @@ import prisma from "@/utils/prisma"; import { extractEmailAddress } from "@/utils/email"; export async function findSenderByEmail({ - userId, + emailAccountId, email, }: { - userId: string; + emailAccountId: string; email: string; }) { if (!email) return null; @@ -13,7 +13,7 @@ export async function findSenderByEmail({ const newsletter = await prisma.newsletter.findFirst({ where: { - userId, + emailAccountId, email: { contains: extractedEmail }, }, }); From 33a74133506e1e4b3a74d7d949740e1ec97a89c7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 01:42:47 +0300 Subject: [PATCH 003/176] move over more code --- .../app/(app)/automation/onboarding/page.tsx | 29 ++++------ apps/web/app/(app)/setup/page.tsx | 16 ++---- .../api/ai/analyze-sender-pattern/route.ts | 46 +++++++--------- .../app/api/google/webhook/process-history.ts | 12 ++-- .../disable-unused-auto-draft/route.ts | 14 ++--- .../scripts/migrateRedisPlansToPostgres.ts | 18 +++--- apps/web/utils/actions/cold-email.ts | 8 ++- apps/web/utils/actions/rule.ts | 55 ++++++++++--------- .../assistant/process-assistant-email.ts | 46 +++++++--------- 9 files changed, 115 insertions(+), 129 deletions(-) diff --git a/apps/web/app/(app)/automation/onboarding/page.tsx b/apps/web/app/(app)/automation/onboarding/page.tsx index 909673332..a8ce3e930 100644 --- a/apps/web/app/(app)/automation/onboarding/page.tsx +++ b/apps/web/app/(app)/automation/onboarding/page.tsx @@ -24,7 +24,7 @@ export default async function OnboardingPage() { ); } -type UserPreferences = Prisma.UserGetPayload<{ +type UserPreferences = Prisma.EmailAccountGetPayload<{ select: { rules: { select: { @@ -46,16 +46,12 @@ async function getUserPreferences({ const emailAccount = await prisma.emailAccount.findUnique({ where: { email }, select: { - user: { + rules: { select: { - rules: { + systemType: true, + actions: { select: { - systemType: true, - actions: { - select: { - type: true, - }, - }, + type: true, }, }, }, @@ -66,16 +62,13 @@ async function getUserPreferences({ if (!emailAccount) return undefined; return { - toReply: getToReplySetting(emailAccount.user.rules), + toReply: getToReplySetting(emailAccount.rules), coldEmails: getColdEmailSetting(emailAccount.coldEmailBlocker), - newsletter: getRuleSetting(SystemType.NEWSLETTER, emailAccount.user.rules), - marketing: getRuleSetting(SystemType.MARKETING, emailAccount.user.rules), - calendar: getRuleSetting(SystemType.CALENDAR, emailAccount.user.rules), - receipt: getRuleSetting(SystemType.RECEIPT, emailAccount.user.rules), - notification: getRuleSetting( - SystemType.NOTIFICATION, - emailAccount.user.rules, - ), + newsletter: getRuleSetting(SystemType.NEWSLETTER, emailAccount.rules), + marketing: getRuleSetting(SystemType.MARKETING, emailAccount.rules), + calendar: getRuleSetting(SystemType.CALENDAR, emailAccount.rules), + receipt: getRuleSetting(SystemType.RECEIPT, emailAccount.rules), + notification: getRuleSetting(SystemType.NOTIFICATION, emailAccount.rules), }; } diff --git a/apps/web/app/(app)/setup/page.tsx b/apps/web/app/(app)/setup/page.tsx index 1f2f574f6..58b1eef11 100644 --- a/apps/web/app/(app)/setup/page.tsx +++ b/apps/web/app/(app)/setup/page.tsx @@ -24,22 +24,18 @@ export default async function SetupPage() { where: { email }, select: { coldEmailBlocker: true, - user: { - select: { - rules: { select: { id: true }, take: 1 }, - newsletters: { - where: { status: { not: null } }, - take: 1, - }, - }, + rules: { select: { id: true }, take: 1 }, + newsletters: { + where: { status: { not: null } }, + take: 1, }, }, }); if (!emailAccount) throw new Error("User not found"); - const isAiAssistantConfigured = emailAccount.user.rules.length > 0; - const isBulkUnsubscribeConfigured = emailAccount.user.newsletters.length > 0; + const isAiAssistantConfigured = emailAccount.rules.length > 0; + const isBulkUnsubscribeConfigured = emailAccount.newsletters.length > 0; const cookieStore = await cookies(); const isReplyTrackerConfigured = cookieStore.get(REPLY_ZERO_ONBOARDING_COOKIE)?.value === "true"; diff --git a/apps/web/app/api/ai/analyze-sender-pattern/route.ts b/apps/web/app/api/ai/analyze-sender-pattern/route.ts index c9d0a622d..639857ade 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/route.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/route.ts @@ -64,9 +64,9 @@ async function process({ email, from }: { email: string; from: string }) { // Check if we've already analyzed this sender const existingCheck = await prisma.newsletter.findUnique({ where: { - email_userId: { + email_emailAccountId: { email: extractEmailAddress(from), - userId: emailAccount.userId, + emailAccountId: emailAccount.email, }, }, }); @@ -129,7 +129,7 @@ async function process({ email, from }: { email: string; from: string }) { const patternResult = await aiDetectRecurringPattern({ emails, user: emailAccount, - rules: emailAccount.user.rules.map((rule) => ({ + rules: emailAccount.rules.map((rule) => ({ name: rule.name, instructions: rule.instructions || "", })), @@ -138,14 +138,14 @@ async function process({ email, from }: { email: string; from: string }) { if (patternResult?.matchedRule) { // Save pattern to DB (adds sender to rule's group) await saveLearnedPattern({ - userId: emailAccount.userId, + email: emailAccount.email, from, ruleName: patternResult.matchedRule, }); } // Record the pattern analysis result - await savePatternCheck({ userId: emailAccount.userId, from }); + await savePatternCheck({ emailAccountId: emailAccount.email, from }); return NextResponse.json({ success: true }); } catch (error) { @@ -166,14 +166,14 @@ async function process({ email, from }: { email: string; from: string }) { * Record that we've analyzed a sender for patterns */ async function savePatternCheck({ - userId, + emailAccountId, from, -}: { userId: string; from: string }) { +}: { emailAccountId: string; from: string }) { await prisma.newsletter.upsert({ where: { - email_userId: { + email_emailAccountId: { email: from, - userId, + emailAccountId, }, }, update: { @@ -182,7 +182,7 @@ async function savePatternCheck({ }, create: { email: from, - userId, + emailAccountId, patternAnalyzed: true, lastAnalyzedAt: new Date(), }, @@ -238,26 +238,26 @@ async function getThreadsFromSender( } async function saveLearnedPattern({ - userId, + email, from, ruleName, }: { - userId: string; + email: string; from: string; ruleName: string; }) { const rule = await prisma.rule.findUnique({ where: { - name_userId: { + name_emailAccountId: { name: ruleName, - userId, + emailAccountId: email, }, }, select: { id: true, groupId: true }, }); if (!rule) { - logger.error("Rule not found", { userId, ruleName }); + logger.error("Rule not found", { email, ruleName }); return; } @@ -267,7 +267,7 @@ async function saveLearnedPattern({ // Create a new group for this rule if one doesn't exist const newGroup = await prisma.group.create({ data: { - userId, + emailAccountId: email, name: ruleName, rule: { connect: { id: rule.id } }, }, @@ -309,16 +309,12 @@ async function getEmailAccountWithRules({ email }: { email: string }) { refresh_token: true, }, }, - user: { + rules: { + where: { enabled: true, instructions: { not: null } }, select: { - rules: { - where: { enabled: true, instructions: { not: null } }, - select: { - id: true, - name: true, - instructions: true, - }, - }, + id: true, + name: true, + instructions: true, }, }, }, diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index 7a7456fc0..853cf0ed6 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -46,12 +46,12 @@ export async function processHistoryForUser( providerAccountId: true, }, }, + rules: { + where: { enabled: true }, + include: { actions: true, categoryFilters: true }, + }, user: { select: { - rules: { - where: { enabled: true }, - include: { actions: true, categoryFilters: true }, - }, premium: { select: { lemonSqueezyRenewsAt: true, @@ -107,7 +107,7 @@ export async function processHistoryForUser( return NextResponse.json({ ok: true }); } - const hasAutomationRules = emailAccount.user.rules.length > 0; + const hasAutomationRules = emailAccount.rules.length > 0; const shouldBlockColdEmails = emailAccount.coldEmailBlocker && emailAccount.coldEmailBlocker !== ColdEmailSetting.DISABLED; @@ -175,7 +175,7 @@ export async function processHistoryForUser( gmail, accessToken: emailAccount.account?.access_token, hasAutomationRules, - rules: emailAccount.user.rules, + rules: emailAccount.rules, hasColdEmailAccess: userHasColdEmailAccess, hasAiAutomationAccess: userHasAiAccess, user: emailAccount, diff --git a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts index e14aadc1d..4512a3bc2 100644 --- a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts +++ b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts @@ -26,7 +26,7 @@ async function disableUnusedAutoDrafts() { // TODO: may need to make this more efficient // Find all users who have the auto-draft feature enabled (have an Action of type DRAFT_EMAIL) - const usersWithAutoDraft = await prisma.user.findMany({ + const usersWithAutoDraft = await prisma.emailAccount.findMany({ where: { rules: { some: { @@ -40,7 +40,7 @@ async function disableUnusedAutoDrafts() { }, }, select: { - id: true, + email: true, rules: { where: { systemType: SystemType.TO_REPLY, @@ -69,7 +69,7 @@ async function disableUnusedAutoDrafts() { const lastTenDrafts = await prisma.executedAction.findMany({ where: { executedRule: { - userId: user.id, + emailAccountId: user.email, rule: { systemType: SystemType.TO_REPLY, }, @@ -96,7 +96,7 @@ async function disableUnusedAutoDrafts() { // Skip if user has fewer than 10 drafts (not enough data to make a decision) if (lastTenDrafts.length < MAX_DRAFTS_TO_CHECK) { logger.info("Skipping user - only has few drafts", { - userId: user.id, + email: user.email, numDrafts: lastTenDrafts.length, }); continue; @@ -110,14 +110,14 @@ async function disableUnusedAutoDrafts() { // If none of the drafts were sent, disable auto-draft if (!anyDraftsSent) { logger.info("Disabling auto-draft for user - last 10 drafts not used", { - userId: user.id, + email: user.email, }); // Delete the DRAFT_EMAIL actions from all TO_REPLY rules await prisma.action.deleteMany({ where: { rule: { - userId: user.id, + emailAccountId: user.email, systemType: SystemType.TO_REPLY, }, type: ActionType.DRAFT_EMAIL, @@ -128,7 +128,7 @@ async function disableUnusedAutoDrafts() { results.usersDisabled++; } } catch (error) { - logger.error("Error processing user", { userId: user.id, error }); + logger.error("Error processing user", { email: user.email, error }); captureException(error); results.errors++; } diff --git a/apps/web/scripts/migrateRedisPlansToPostgres.ts b/apps/web/scripts/migrateRedisPlansToPostgres.ts index f20779cc6..7fd8f631b 100644 --- a/apps/web/scripts/migrateRedisPlansToPostgres.ts +++ b/apps/web/scripts/migrateRedisPlansToPostgres.ts @@ -74,12 +74,12 @@ async function migratePlansFromRedis() { } async function migrateUserPlans(userId: string) { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { id: true }, + const emailAccount = await prisma.emailAccount.findFirst({ + where: { userId }, + select: { email: true, userId: true }, }); - if (!user) { - console.error(`User not found for user ${userId}`); + if (!emailAccount) { + console.error(`Email account not found for user ${userId}`); processedUserIds.push(userId); console.log("Processed user IDs:", processedUserIds); return; @@ -97,14 +97,14 @@ async function migrateUserPlans(userId: string) { } const userRules = await prisma.rule.findMany({ - where: { userId }, + where: { emailAccountId: emailAccount.email }, select: { id: true }, }); const threadIds = plans.map((plan) => plan.threadId); const existingPlans = await prisma.executedRule.findMany({ where: { - userId, + emailAccountId: emailAccount.email, threadId: { in: threadIds }, }, select: { messageId: true, threadId: true }, @@ -155,7 +155,7 @@ async function migrateUserPlans(userId: string) { messageId: plan.messageId, reason: plan.reason, ruleId: plan.rule?.id, - userId, + emailAccountId: emailAccount.email, status: plan.executed ? "APPLIED" : "PENDING", automated: false, actionItems: { @@ -205,7 +205,7 @@ async function migrateUserPlans(userId: string) { messageId: plan.messageId, status: "SKIPPED" as const, automated: false, - userId, + emailAccountId: emailAccount.email, }; await prisma.executedRule.create({ data }); } diff --git a/apps/web/utils/actions/cold-email.ts b/apps/web/utils/actions/cold-email.ts index 0cecc8e8c..e14c33df7 100644 --- a/apps/web/utils/actions/cold-email.ts +++ b/apps/web/utils/actions/cold-email.ts @@ -62,7 +62,8 @@ export const markNotColdEmailAction = withActionInstrumentation( "markNotColdEmail", async (body: MarkNotColdEmailBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, error } = markNotColdEmailBody.safeParse(body); if (error) return { error: error.message }; @@ -74,7 +75,10 @@ export const markNotColdEmailAction = withActionInstrumentation( await Promise.all([ prisma.coldEmail.update({ where: { - userId_fromEmail: { userId: session.user.id, fromEmail: sender }, + emailAccountId_fromEmail: { + emailAccountId: email, + fromEmail: sender, + }, }, data: { status: ColdEmailStatus.USER_REJECTED_COLD, diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index b685233f3..486d1b8d9 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -89,7 +89,7 @@ export const createRuleAction = withActionInstrumentation( }, } : undefined, - userId: session.user.id, + emailAccountId: email, conditionalOperator: body.conditionalOperator || LogicalOperator.AND, systemType: body.systemType || undefined, // conditions @@ -144,7 +144,7 @@ export const updateRuleAction = withActionInstrumentation( try { const currentRule = await prisma.rule.findUnique({ - where: { id: body.id, userId: session.user.id }, + where: { id: body.id, emailAccountId: email }, include: { actions: true, categoryFilters: true, group: true }, }); if (!currentRule) return { error: "Rule not found" }; @@ -160,7 +160,7 @@ export const updateRuleAction = withActionInstrumentation( const [updatedRule] = await prisma.$transaction([ // update rule prisma.rule.update({ - where: { id: body.id, userId: session.user.id }, + where: { id: body.id, emailAccountId: email }, data: { automate: body.automate ?? undefined, runOnThreads: body.runOnThreads ?? undefined, @@ -270,14 +270,13 @@ export const updateRuleInstructionsAction = withActionInstrumentation( if (error) return { error: error.message }; const currentRule = await prisma.rule.findUnique({ - where: { id: body.id, userId: session.user.id }, + where: { id: body.id, emailAccountId: email }, include: { actions: true, categoryFilters: true, group: true }, }); if (!currentRule) return { error: "Rule not found" }; await updateRuleInstructionsAndPromptFile({ email, - userId: session.user.id, ruleId: body.id, instructions: body.instructions, currentRule, @@ -292,13 +291,14 @@ export const updateRuleSettingsAction = withActionInstrumentation( "updateRuleSettings", async (options: UpdateRuleSettingsBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data: body, error } = updateRuleSettingsBody.safeParse(options); if (error) return { error: error.message }; await prisma.rule.update({ - where: { id: body.id, userId: session.user.id }, + where: { id: body.id, emailAccountId: email }, data: { instructions: body.instructions }, }); @@ -312,15 +312,16 @@ export const enableDraftRepliesAction = withActionInstrumentation( "enableDraftReplies", async (options: EnableDraftRepliesBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, error } = enableDraftRepliesBody.safeParse(options); if (error) return { error: error.message }; const rule = await prisma.rule.findUnique({ where: { - userId_systemType: { - userId: session.user.id, + emailAccountId_systemType: { + emailAccountId: email, systemType: SystemType.TO_REPLY, }, }, @@ -357,13 +358,13 @@ export const deleteRuleAction = withActionInstrumentation( include: { actions: true, categoryFilters: true, group: true }, }); if (!rule) return; // already deleted - if (rule.userId !== session.user.id) + if (rule.emailAccountId !== email) return { error: "You don't have permission to delete this rule" }; try { await deleteRule({ ruleId, - userId: session.user.id, + email, groupId: rule.groupId, }); @@ -496,10 +497,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( categoryAction: "label" | "label_archive", label: string, systemType: SystemType, - userId: string, + emailAccountId: string, ) { const existingRule = await prisma.rule.findUnique({ - where: { userId_systemType: { userId, systemType } }, + where: { emailAccountId_systemType: { emailAccountId, systemType } }, }); if (existingRule) { @@ -534,7 +535,7 @@ export const createRulesOnboardingAction = withActionInstrumentation( const promise = prisma.rule .create({ data: { - userId, + emailAccountId, name, instructions, systemType, @@ -568,10 +569,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( } } - async function deleteRule(systemType: SystemType, userId: string) { + async function deleteRule(systemType: SystemType, emailAccountId: string) { const promise = async () => { const rule = await prisma.rule.findUnique({ - where: { userId_systemType: { userId, systemType } }, + where: { emailAccountId_systemType: { emailAccountId, systemType } }, }); if (!rule) return; await prisma.rule.delete({ where: { id: rule.id } }); @@ -589,10 +590,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( data.newsletter, "Newsletter", SystemType.NEWSLETTER, - userId, + email, ); } else { - deleteRule(SystemType.NEWSLETTER, userId); + deleteRule(SystemType.NEWSLETTER, email); } // marketing @@ -605,10 +606,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( data.marketing, "Marketing", SystemType.MARKETING, - userId, + email, ); } else { - deleteRule(SystemType.MARKETING, userId); + deleteRule(SystemType.MARKETING, email); } // calendar @@ -621,10 +622,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( data.calendar, "Calendar", SystemType.CALENDAR, - userId, + email, ); } else { - deleteRule(SystemType.CALENDAR, userId); + deleteRule(SystemType.CALENDAR, email); } // receipt @@ -637,10 +638,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( data.receipt, "Receipt", SystemType.RECEIPT, - userId, + email, ); } else { - deleteRule(SystemType.RECEIPT, userId); + deleteRule(SystemType.RECEIPT, email); } // notification @@ -653,10 +654,10 @@ export const createRulesOnboardingAction = withActionInstrumentation( data.notification, "Notification", SystemType.NOTIFICATION, - userId, + email, ); } else { - deleteRule(SystemType.NOTIFICATION, userId); + deleteRule(SystemType.NOTIFICATION, email); } await Promise.allSettled(promises); diff --git a/apps/web/utils/assistant/process-assistant-email.ts b/apps/web/utils/assistant/process-assistant-email.ts index 55621f12e..b4799dfb4 100644 --- a/apps/web/utils/assistant/process-assistant-email.ts +++ b/apps/web/utils/assistant/process-assistant-email.ts @@ -98,7 +98,7 @@ async function processAssistantEmailInternal({ const originalMessageId = firstMessageToAssistant.headers["in-reply-to"]; const originalMessage = await getOriginalMessage(originalMessageId, gmail); - const [user, executedRule, senderCategory] = await Promise.all([ + const [emailAccount, executedRule, senderCategory] = await Promise.all([ prisma.emailAccount.findUnique({ where: { email: userEmail }, select: { @@ -108,37 +108,33 @@ async function processAssistantEmailInternal({ aiProvider: true, aiModel: true, aiApiKey: true, - user: { - select: { - rules: { - include: { - actions: true, - categoryFilters: true, - group: { + rules: { + include: { + actions: true, + categoryFilters: true, + group: { + select: { + id: true, + name: true, + items: { select: { id: true, - name: true, - items: { - select: { - id: true, - type: true, - value: true, - }, - }, + type: true, + value: true, }, }, }, }, - categories: true, }, }, + categories: true, }, }), originalMessage ? prisma.executedRule.findUnique({ where: { - unique_user_thread_message: { - userId, + unique_emailAccount_thread_message: { + emailAccountId: userEmail, threadId: originalMessage.threadId, messageId: originalMessage.id, }, @@ -157,9 +153,9 @@ async function processAssistantEmailInternal({ originalMessage ? prisma.newsletter.findUnique({ where: { - email_userId: { - userId, + email_emailAccountId: { email: extractEmailAddress(originalMessage.headers.from), + emailAccountId: userEmail, }, }, select: { @@ -169,7 +165,7 @@ async function processAssistantEmailInternal({ : null, ]); - if (!user) { + if (!emailAccount) { logger.error("User not found", loggerOptions); return; } @@ -213,12 +209,12 @@ async function processAssistantEmailInternal({ } const result = await processUserRequest({ - user, - rules: user.user.rules, + user: emailAccount, + rules: emailAccount.rules, originalEmail: originalMessage, messages, matchedRule: executedRule?.rule || null, - categories: user.user.categories.length ? user.user.categories : null, + categories: emailAccount.categories.length ? emailAccount.categories : null, senderCategory: senderCategory?.category?.name ?? null, }); From 5d34941c28850bc48cf3328dd4f604bf9e54478b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 01:57:24 +0300 Subject: [PATCH 004/176] move over more relations --- apps/web/app/(app)/automation/page.tsx | 5 +- .../(app)/automation/rule/[ruleId]/page.tsx | 5 +- apps/web/app/(app)/clean/helpers.ts | 10 ++-- apps/web/app/(app)/clean/run/page.tsx | 13 +++--- apps/web/app/(app)/smart-categories/page.tsx | 4 +- apps/web/app/api/clean/gmail/route.ts | 2 +- .../api/google/labels/create/controller.ts | 11 +++-- apps/web/app/api/resend/summary/route.ts | 21 ++++----- .../api/user/group/[groupId]/items/route.ts | 15 +++--- apps/web/app/api/user/group/route.ts | 10 ++-- apps/web/prisma/schema.prisma | 24 +++++----- apps/web/utils/actions/knowledge.ts | 18 ++++---- apps/web/utils/actions/mail.ts | 13 +++--- apps/web/utils/actions/user.ts | 2 +- apps/web/utils/ai/actions.ts | 4 +- apps/web/utils/redis/clean.ts | 14 ++++-- .../web/utils/reply-tracker/generate-draft.ts | 2 +- apps/web/utils/reply-tracker/inbound.ts | 46 +++++++++++-------- apps/web/utils/reply-tracker/outbound.ts | 16 +++---- apps/web/utils/unsubscribe.ts | 8 +++- 20 files changed, 127 insertions(+), 116 deletions(-) diff --git a/apps/web/app/(app)/automation/page.tsx b/apps/web/app/(app)/automation/page.tsx index b06f3bee4..e8872e8cb 100644 --- a/apps/web/app/(app)/automation/page.tsx +++ b/apps/web/app/(app)/automation/page.tsx @@ -23,7 +23,8 @@ export const maxDuration = 300; // Applies to the actions export default async function AutomationPage() { const session = await auth(); - if (!session?.user) redirect("/login"); + const email = session?.user.email; + if (!email) redirect("/login"); // onboarding redirect const cookieStore = await cookies(); @@ -32,7 +33,7 @@ export default async function AutomationPage() { if (!viewedOnboarding) { const hasRule = await prisma.rule.findFirst({ - where: { userId: session.user.id }, + where: { emailAccountId: email }, select: { id: true }, }); diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/page.tsx b/apps/web/app/(app)/automation/rule/[ruleId]/page.tsx index 5c3f45784..af9735549 100644 --- a/apps/web/app/(app)/automation/rule/[ruleId]/page.tsx +++ b/apps/web/app/(app)/automation/rule/[ruleId]/page.tsx @@ -13,10 +13,11 @@ export default async function RulePage(props: { const searchParams = await props.searchParams; const params = await props.params; const session = await auth(); - if (!session?.user) redirect("/login"); + const email = session?.user.email; + if (!email) redirect("/login"); const rule = await prisma.rule.findUnique({ - where: { id: params.ruleId, userId: session.user.id }, + where: { id: params.ruleId, emailAccountId: email }, include: { actions: true, categoryFilters: true, diff --git a/apps/web/app/(app)/clean/helpers.ts b/apps/web/app/(app)/clean/helpers.ts index ae74de57e..933eda5e9 100644 --- a/apps/web/app/(app)/clean/helpers.ts +++ b/apps/web/app/(app)/clean/helpers.ts @@ -1,20 +1,20 @@ import prisma from "utils/prisma"; export async function getJobById({ - userId, + email, jobId, }: { - userId: string; + email: string; jobId: string; }) { return await prisma.cleanupJob.findUnique({ - where: { id: jobId, userId }, + where: { id: jobId, email }, }); } -export async function getLastJob(userId: string) { +export async function getLastJob({ email }: { email: string }) { return await prisma.cleanupJob.findFirst({ - where: { userId }, + where: { email }, orderBy: { createdAt: "desc" }, }); } diff --git a/apps/web/app/(app)/clean/run/page.tsx b/apps/web/app/(app)/clean/run/page.tsx index d79411292..671e489e1 100644 --- a/apps/web/app/(app)/clean/run/page.tsx +++ b/apps/web/app/(app)/clean/run/page.tsx @@ -15,20 +15,21 @@ export default async function CleanRunPage(props: { const session = await auth(); if (!session?.user.email) return
Not authenticated
; - const userId = session.user.id; const userEmail = session.user.email; - const threads = await getThreadsByJobId(userId, jobId); + const threads = await getThreadsByJobId({ emailAccountId: userEmail, jobId }); const job = jobId - ? await getJobById({ userId, jobId }) - : await getLastJob(userId); + ? await getJobById({ email: userEmail, jobId }) + : await getLastJob({ email: userEmail }); if (!job) return Job not found; const [total, done] = await Promise.all([ - prisma.cleanupThread.count({ where: { jobId, userId } }), - prisma.cleanupThread.count({ where: { jobId, userId, archived: true } }), + prisma.cleanupThread.count({ where: { jobId, emailAccountId: userEmail } }), + prisma.cleanupThread.count({ + where: { jobId, emailAccountId: userEmail, archived: true }, + }), ]); return ( diff --git a/apps/web/app/(app)/smart-categories/page.tsx b/apps/web/app/(app)/smart-categories/page.tsx index 9b5035ee7..518184e06 100644 --- a/apps/web/app/(app)/smart-categories/page.tsx +++ b/apps/web/app/(app)/smart-categories/page.tsx @@ -37,14 +37,14 @@ export default async function CategoriesPage() { const [senders, categories, emailAccount, progress] = await Promise.all([ prisma.newsletter.findMany({ - where: { userId: session.user.id, categoryId: { not: null } }, + where: { emailAccountId: email, categoryId: { not: null } }, select: { id: true, email: true, category: { select: { id: true, description: true, name: true } }, }, }), - getUserCategoriesWithRules(session.user.id), + getUserCategoriesWithRules(email), prisma.emailAccount.findUnique({ where: { email }, select: { autoCategorizeSenders: true }, diff --git a/apps/web/app/api/clean/gmail/route.ts b/apps/web/app/api/clean/gmail/route.ts index 355913344..e62245580 100644 --- a/apps/web/app/api/clean/gmail/route.ts +++ b/apps/web/app/api/clean/gmail/route.ts @@ -116,7 +116,7 @@ async function saveToDatabase({ }) { await prisma.cleanupThread.create({ data: { - user: { connect: { email } }, + emailAccount: { connect: { email } }, threadId, archived: archive, job: { connect: { id: jobId } }, diff --git a/apps/web/app/api/google/labels/create/controller.ts b/apps/web/app/api/google/labels/create/controller.ts index ba34d7c0b..98af7fc9f 100644 --- a/apps/web/app/api/google/labels/create/controller.ts +++ b/apps/web/app/api/google/labels/create/controller.ts @@ -15,7 +15,8 @@ export type CreateLabelResponse = Awaited>; export async function createLabel(body: CreateLabelBody) { const session = await auth(); - if (!session?.user.email) throw new SafeError("Not authenticated"); + const email = session?.user.email; + if (!email) throw new SafeError("Not authenticated"); const gmail = getGmailClient(session); const label = await getOrCreateLabel({ gmail, name: body.name }); @@ -23,9 +24,9 @@ export async function createLabel(body: CreateLabelBody) { const dbPromise = prisma.label.upsert({ where: { - gmailLabelId_userId: { + gmailLabelId_emailAccountId: { gmailLabelId: label.id, - userId: session.user.id, + emailAccountId: email, }, }, update: {}, @@ -34,12 +35,12 @@ export async function createLabel(body: CreateLabelBody) { description: body.description, gmailLabelId: label.id, enabled: true, - userId: session.user.id, + emailAccountId: email, }, }); const redisPromise = saveUserLabel({ - email: session.user.email, + email, label: { id: label.id, name: body.name, description: body.description }, }); diff --git a/apps/web/app/api/resend/summary/route.ts b/apps/web/app/api/resend/summary/route.ts index 4ff3cf507..749d66bb9 100644 --- a/apps/web/app/api/resend/summary/route.ts +++ b/apps/web/app/api/resend/summary/route.ts @@ -53,10 +53,9 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { } } - const user = await prisma.user.findUnique({ + const user = await prisma.emailAccount.findUnique({ where: { email }, select: { - id: true, coldEmails: { where: { createdAt: { gt: cutOffDate } } }, _count: { select: { @@ -68,7 +67,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { }, }, }, - accounts: { + account: { select: { access_token: true, }, @@ -98,7 +97,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { prisma.threadTracker.groupBy({ by: ["type"], where: { - userId: user.id, + emailAccountId: email, resolved: false, }, _count: true, @@ -106,7 +105,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { // needs reply prisma.threadTracker.findMany({ where: { - userId: user.id, + emailAccountId: email, type: ThreadTrackerType.NEEDS_REPLY, resolved: false, }, @@ -117,7 +116,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { // awaiting reply prisma.threadTracker.findMany({ where: { - userId: user.id, + emailAccountId: email, type: ThreadTrackerType.AWAITING, resolved: false, // only show emails that are more than 3 days overdue @@ -163,8 +162,8 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { messagesCount: messageIds.length, }); - const messages = user.accounts?.[0]?.access_token - ? await getMessagesBatch(messageIds, user.accounts[0].access_token) + const messages = user.account.access_token + ? await getMessagesBatch(messageIds, user.account.access_token) : []; const messageMap = Object.fromEntries( @@ -216,8 +215,8 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION], }); - async function sendEmail(userId: string) { - const token = await createUnsubscribeToken(userId); + async function sendEmail({ email }: { email: string }) { + const token = await createUnsubscribeToken({ email }); return sendSummaryEmail({ to: email, @@ -237,7 +236,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { } await Promise.all([ - shouldSendEmail ? sendEmail(user.id) : Promise.resolve(), + shouldSendEmail ? sendEmail({ email }) : Promise.resolve(), prisma.emailAccount.update({ where: { email }, data: { lastSummaryEmailAt: new Date() }, diff --git a/apps/web/app/api/user/group/[groupId]/items/route.ts b/apps/web/app/api/user/group/[groupId]/items/route.ts index c04a556d6..40db41c4b 100644 --- a/apps/web/app/api/user/group/[groupId]/items/route.ts +++ b/apps/web/app/api/user/group/[groupId]/items/route.ts @@ -6,14 +6,14 @@ import { withError } from "@/utils/middleware"; export type GroupItemsResponse = Awaited>; async function getGroupItems({ - userId, + email, groupId, }: { - userId: string; + email: string; groupId: string; }) { const group = await prisma.group.findUnique({ - where: { id: groupId, userId }, + where: { id: groupId, emailAccountId: email }, select: { name: true, prompt: true, @@ -26,16 +26,13 @@ async function getGroupItems({ export const GET = withError(async (_request: Request, { params }) => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Group id required" }); - const result = await getGroupItems({ - userId: session.user.id, - groupId, - }); + const result = await getGroupItems({ email, groupId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/group/route.ts b/apps/web/app/api/user/group/route.ts index b6c28eda7..69c5b21f3 100644 --- a/apps/web/app/api/user/group/route.ts +++ b/apps/web/app/api/user/group/route.ts @@ -5,9 +5,9 @@ import { withError } from "@/utils/middleware"; export type GroupsResponse = Awaited>; -async function getGroups({ userId }: { userId: string }) { +async function getGroups({ email }: { email: string }) { const groups = await prisma.group.findMany({ - where: { userId }, + where: { emailAccountId: email }, select: { id: true, name: true, @@ -20,10 +20,10 @@ async function getGroups({ userId }: { userId: string }) { export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const result = await getGroups({ userId: session.user.id }); + const result = await getGroups({ email }); return NextResponse.json(result); }); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index f0df7fed9..dd4e85359 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -87,9 +87,7 @@ model User { premiumAdminId String? premiumAdmin Premium? @relation(fields: [premiumAdminId], references: [id]) - apiKeys ApiKey[] - unsubscribeTokens EmailToken[] - knowledges Knowledge[] + apiKeys ApiKey[] emailAccounts EmailAccount[] @@ -138,6 +136,8 @@ model EmailAccount { cleanupJobs CleanupJob[] cleanupThreads CleanupThread[] emailMessages EmailMessage[] + emailTokens EmailToken[] + knowledge Knowledge[] @@index([lastSummaryEmailAt]) } @@ -487,10 +487,10 @@ model Knowledge { title String content String - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + emailAccount EmailAccount? @relation(fields: [emailAccountId], references: [email]) + emailAccountId String? - @@unique([userId, title]) + @@unique([emailAccountId, title]) } model ApiKey { @@ -508,12 +508,12 @@ model ApiKey { } model EmailToken { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - token String @unique - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - expiresAt DateTime + id String @id @default(cuid()) + createdAt DateTime @default(now()) + token String @unique + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + expiresAt DateTime // action EmailTokenAction @default(UNSUBSCRIBE) } diff --git a/apps/web/utils/actions/knowledge.ts b/apps/web/utils/actions/knowledge.ts index 76ec9e1ed..075c02c0d 100644 --- a/apps/web/utils/actions/knowledge.ts +++ b/apps/web/utils/actions/knowledge.ts @@ -17,8 +17,8 @@ export const createKnowledgeAction = withActionInstrumentation( "createKnowledge", async (unsafeData: CreateKnowledgeBody) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, success, error } = createKnowledgeBody.safeParse(unsafeData); if (!success) return { error: error.message }; @@ -26,7 +26,7 @@ export const createKnowledgeAction = withActionInstrumentation( await prisma.knowledge.create({ data: { ...data, - userId, + emailAccountId: email, }, }); @@ -38,14 +38,14 @@ export const updateKnowledgeAction = withActionInstrumentation( "updateKnowledge", async (unsafeData: UpdateKnowledgeBody) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, success, error } = updateKnowledgeBody.safeParse(unsafeData); if (!success) return { error: error.message }; await prisma.knowledge.update({ - where: { id: data.id, userId }, + where: { id: data.id, emailAccountId: email }, data: { title: data.title, content: data.content, @@ -60,14 +60,14 @@ export const deleteKnowledgeAction = withActionInstrumentation( "deleteKnowledge", async (unsafeData: DeleteKnowledgeBody) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, success, error } = deleteKnowledgeBody.safeParse(unsafeData); if (!success) return { error: error.message }; await prisma.knowledge.delete({ - where: { id: data.id, userId }, + where: { id: data.id, emailAccountId: email }, }); revalidatePath("/automation"); diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index b383d816d..05a4f086d 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -181,9 +181,8 @@ export const updateLabelsAction = withActionInstrumentation( labels: Pick[], ) => { const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const userId = session.user.id; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const enabledLabels = labels.filter((label) => label.enabled); const disabledLabels = labels.filter((label) => !label.enabled); @@ -193,13 +192,13 @@ export const updateLabelsAction = withActionInstrumentation( const { name, description, enabled, gmailLabelId } = label; return prisma.label.upsert({ - where: { name_userId: { name, userId } }, + where: { name_emailAccountId: { name, emailAccountId: email } }, create: { gmailLabelId, name, description, enabled, - user: { connect: { id: userId } }, + emailAccount: { connect: { email } }, }, update: { name, @@ -210,14 +209,14 @@ export const updateLabelsAction = withActionInstrumentation( }), prisma.label.deleteMany({ where: { - userId, + emailAccountId: email, name: { in: disabledLabels.map((label) => label.name) }, }, }), ]); await saveUserLabels({ - email: session.user.email, + email, labels: enabledLabels.map((l) => ({ ...l, id: l.gmailLabelId, diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index b7a29ba19..56dc20365 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -92,7 +92,7 @@ export const resetAnalyticsAction = withActionInstrumentation( if (!session?.user.email) return { error: "Not logged in" }; await prisma.emailMessage.deleteMany({ - where: { userId: session.user.id }, + where: { emailAccountId: session.user.email }, }); }, ); diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index ac5ed5f58..57ad7b407 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -215,11 +215,9 @@ const track_thread: ActionFunction = async ( email, _args, userEmail, - executedRule, ) => { await coordinateReplyProcess({ - userId: executedRule.userId, - email: userEmail, + emailAccountId: userEmail, threadId: email.threadId, messageId: email.id, sentAt: internalDateToDate(email.internalDate), diff --git a/apps/web/utils/redis/clean.ts b/apps/web/utils/redis/clean.ts index 7dc4f7a86..cf0fdde8e 100644 --- a/apps/web/utils/redis/clean.ts +++ b/apps/web/utils/redis/clean.ts @@ -72,12 +72,16 @@ async function getThread(email: string, jobId: string, threadId: string) { return redis.get(key); } -export async function getThreadsByJobId( - userId: string, - jobId: string, +export async function getThreadsByJobId({ + emailAccountId, + jobId, limit = 1000, -) { - const pattern = `thread:${userId}:${jobId}:*`; +}: { + emailAccountId: string; + jobId: string; + limit?: number; +}) { + const pattern = `thread:${emailAccountId}:${jobId}:*`; const keys = []; let cursor = 0; diff --git a/apps/web/utils/reply-tracker/generate-draft.ts b/apps/web/utils/reply-tracker/generate-draft.ts index 1344cf28c..25bedb993 100644 --- a/apps/web/utils/reply-tracker/generate-draft.ts +++ b/apps/web/utils/reply-tracker/generate-draft.ts @@ -138,7 +138,7 @@ async function generateDraftContent( // 1. Get knowledge base entries const knowledgeBase = await prisma.knowledge.findMany({ - where: { userId: user.userId }, + where: { emailAccountId: user.email }, orderBy: { updatedAt: "desc" }, }); diff --git a/apps/web/utils/reply-tracker/inbound.ts b/apps/web/utils/reply-tracker/inbound.ts index 2509791ab..556810ea9 100644 --- a/apps/web/utils/reply-tracker/inbound.ts +++ b/apps/web/utils/reply-tracker/inbound.ts @@ -17,23 +17,20 @@ import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; * 2. Managing Gmail labels */ export async function coordinateReplyProcess({ - userId, - email, + emailAccountId, threadId, messageId, sentAt, gmail, }: { - userId: string; - email: string; + emailAccountId: string; threadId: string; messageId: string; sentAt: Date; gmail: gmail_v1.Gmail; }) { const logger = createScopedLogger("reply-tracker/inbound").with({ - userId, - email, + email: emailAccountId, threadId, messageId, }); @@ -43,7 +40,12 @@ export async function coordinateReplyProcess({ const awaitingReplyLabelId = await getAwaitingReplyLabel(gmail); // Process in parallel for better performance - const dbPromise = updateThreadTrackers(userId, threadId, messageId, sentAt); + const dbPromise = updateThreadTrackers({ + emailAccountId, + threadId, + messageId, + sentAt, + }); const labelsPromise = removeThreadLabel( gmail, threadId, @@ -69,17 +71,22 @@ export async function coordinateReplyProcess({ /** * Updates thread trackers in the database - resolves AWAITING trackers and creates a NEEDS_REPLY tracker */ -async function updateThreadTrackers( - userId: string, - threadId: string, - messageId: string, - sentAt: Date, -) { +async function updateThreadTrackers({ + emailAccountId, + threadId, + messageId, + sentAt, +}: { + emailAccountId: string; + threadId: string; + messageId: string; + sentAt: Date; +}) { return prisma.$transaction([ // Resolve existing AWAITING trackers prisma.threadTracker.updateMany({ where: { - userId, + emailAccountId, threadId, type: ThreadTrackerType.AWAITING, }, @@ -90,15 +97,15 @@ async function updateThreadTrackers( // Create new NEEDS_REPLY tracker prisma.threadTracker.upsert({ where: { - userId_threadId_messageId: { - userId, + emailAccountId_threadId_messageId: { + emailAccountId, threadId, messageId, }, }, update: {}, create: { - userId, + emailAccountId, threadId, messageId, type: ThreadTrackerType.NEEDS_REPLY, @@ -120,7 +127,7 @@ export async function handleInboundReply( const replyTrackingRules = await prisma.rule.findMany({ where: { - userId: user.userId, + emailAccountId: user.email, instructions: { not: null }, actions: { some: { @@ -144,8 +151,7 @@ export async function handleInboundReply( if (replyTrackingRules.some((rule) => rule.id === result.rule?.id)) { await coordinateReplyProcess({ - userId: user.userId, - email: user.email, + emailAccountId: user.email, threadId: message.threadId, messageId: message.id, sentAt: internalDateToDate(message.internalDate), diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 52c43c7e4..9ce00a9bd 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -79,7 +79,7 @@ export async function handleOutboundReply( logger.info("Needs reply. Creating reply tracker outbound"); await createReplyTrackerOutbound({ gmail, - userId: user.userId, + email: user.email, threadId: message.threadId, messageId: message.id, awaitingReplyLabelId, @@ -93,7 +93,7 @@ export async function handleOutboundReply( async function createReplyTrackerOutbound({ gmail, - userId, + email, threadId, messageId, awaitingReplyLabelId, @@ -101,7 +101,7 @@ async function createReplyTrackerOutbound({ logger, }: { gmail: gmail_v1.Gmail; - userId: string; + email: string; threadId: string; messageId: string; awaitingReplyLabelId: string; @@ -112,15 +112,15 @@ async function createReplyTrackerOutbound({ const upsertPromise = prisma.threadTracker.upsert({ where: { - userId_threadId_messageId: { - userId, + emailAccountId_threadId_messageId: { + emailAccountId: email, threadId, messageId, }, }, update: {}, create: { - userId, + emailAccountId: email, threadId, messageId, type: ThreadTrackerType.AWAITING, @@ -154,13 +154,13 @@ async function createReplyTrackerOutbound({ async function resolveReplyTrackers( gmail: gmail_v1.Gmail, - userId: string, + email: string, threadId: string, needsReplyLabelId: string, ) { const updateDbPromise = prisma.threadTracker.updateMany({ where: { - userId, + emailAccountId: email, threadId, resolved: false, type: ThreadTrackerType.NEEDS_REPLY, diff --git a/apps/web/utils/unsubscribe.ts b/apps/web/utils/unsubscribe.ts index 382d26ad3..3c96e5286 100644 --- a/apps/web/utils/unsubscribe.ts +++ b/apps/web/utils/unsubscribe.ts @@ -2,13 +2,17 @@ import addDays from "date-fns/addDays"; import prisma from "./prisma"; import { generateSecureToken } from "./api-key"; -export async function createUnsubscribeToken(userId: string) { +export async function createUnsubscribeToken({ + email, +}: { + email: string; +}) { const token = generateSecureToken(); await prisma.emailToken.create({ data: { token, - userId, + emailAccountId: email, expiresAt: addDays(new Date(), 30), }, }); From 5e6659c566ca7412c4745f2cbcbf5785bfcba4ea Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 02:04:35 +0300 Subject: [PATCH 005/176] more move overs --- apps/web/app/(app)/clean/page.tsx | 5 +++-- .../app/(app)/reply-zero/AwaitingReply.tsx | 10 ++++----- apps/web/app/(app)/reply-zero/NeedsReply.tsx | 10 ++++----- .../(app)/reply-zero/ReplyTrackerEmails.tsx | 12 +++++----- apps/web/app/(app)/reply-zero/Resolved.tsx | 12 +++++----- .../app/(app)/reply-zero/fetch-trackers.ts | 10 ++++----- apps/web/app/(app)/reply-zero/page.tsx | 22 +++++++++---------- apps/web/app/api/user/draft-actions/route.ts | 10 ++++----- apps/web/utils/actions/group.ts | 12 +++++----- apps/web/utils/actions/permissions.ts | 9 +++++--- apps/web/utils/actions/reply-tracking.ts | 10 ++++----- .../utils/redis/reply-tracker-analyzing.ts | 16 +++++++------- .../web/utils/reply-tracker/draft-tracking.ts | 10 ++++----- 13 files changed, 73 insertions(+), 75 deletions(-) diff --git a/apps/web/app/(app)/clean/page.tsx b/apps/web/app/(app)/clean/page.tsx index f3c8dc366..73259c075 100644 --- a/apps/web/app/(app)/clean/page.tsx +++ b/apps/web/app/(app)/clean/page.tsx @@ -6,9 +6,10 @@ import { Card } from "@/components/ui/card"; export default async function CleanPage() { const session = await auth(); - if (!session?.user.id) return
Not authenticated
; + const email = session?.user.email; + if (!email) return
Not authenticated
; - const lastJob = await getLastJob(session.user.id); + const lastJob = await getLastJob({ email }); if (!lastJob) redirect("/clean/onboarding"); return ( diff --git a/apps/web/app/(app)/reply-zero/AwaitingReply.tsx b/apps/web/app/(app)/reply-zero/AwaitingReply.tsx index 6b2b1b8dc..e321c97de 100644 --- a/apps/web/app/(app)/reply-zero/AwaitingReply.tsx +++ b/apps/web/app/(app)/reply-zero/AwaitingReply.tsx @@ -4,20 +4,18 @@ import { getPaginatedThreadTrackers } from "./fetch-trackers"; import type { TimeRange } from "./date-filter"; export async function AwaitingReply({ - userId, - userEmail, + email, page, timeRange, isAnalyzing, }: { - userId: string; - userEmail: string; + email: string; page: number; timeRange: TimeRange; isAnalyzing: boolean; }) { const { trackers, totalPages } = await getPaginatedThreadTrackers({ - userId, + email, type: ThreadTrackerType.AWAITING, page, timeRange, @@ -26,7 +24,7 @@ export async function AwaitingReply({ return ( void; @@ -307,7 +307,7 @@ function Row({ } subject={message.headers.subject} snippet={message.snippet} - userEmail={userEmail} + userEmail={email} threadId={message.threadId} messageId={message.id} hideViewEmailButton diff --git a/apps/web/app/(app)/reply-zero/Resolved.tsx b/apps/web/app/(app)/reply-zero/Resolved.tsx index 7df77404c..b9fb974d2 100644 --- a/apps/web/app/(app)/reply-zero/Resolved.tsx +++ b/apps/web/app/(app)/reply-zero/Resolved.tsx @@ -6,13 +6,11 @@ import { Prisma } from "@prisma/client"; const PAGE_SIZE = 20; export async function Resolved({ - userId, - userEmail, + email, page, timeRange, }: { - userId: string; - userEmail: string; + email: string; page: number; timeRange: TimeRange; }) { @@ -24,7 +22,7 @@ export async function Resolved({ prisma.$queryRaw>` SELECT MAX(id) as id FROM "ThreadTracker" - WHERE "userId" = ${userId} + WHERE "emailAccountId" = ${email} ${dateFilter ? Prisma.sql`AND "sentAt" <= (${dateFilter}->>'lte')::timestamp` : Prisma.empty} GROUP BY "threadId" HAVING bool_and(resolved) = true @@ -35,7 +33,7 @@ export async function Resolved({ prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(DISTINCT "threadId") as count FROM "ThreadTracker" - WHERE "userId" = ${userId} + WHERE "emailAccountId" = ${email} ${dateFilter ? Prisma.sql`AND "sentAt" <= (${dateFilter}->>'lte')::timestamp` : Prisma.empty} GROUP BY "threadId" HAVING bool_and(resolved) = true @@ -54,7 +52,7 @@ export async function Resolved({ return ( ` SELECT COUNT(DISTINCT "threadId") as count FROM "ThreadTracker" - WHERE "userId" = ${userId} + WHERE "emailAccountId" = ${email} AND "resolved" = false AND "type" = ${type}::text::"ThreadTrackerType" AND "sentAt" <= ${dateFilter.lte} @@ -45,7 +45,7 @@ export async function getPaginatedThreadTrackers({ : prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(DISTINCT "threadId") as count FROM "ThreadTracker" - WHERE "userId" = ${userId} + WHERE "emailAccountId" = ${email} AND "resolved" = false AND "type" = ${type}::text::"ThreadTrackerType" `, diff --git a/apps/web/app/(app)/reply-zero/page.tsx b/apps/web/app/(app)/reply-zero/page.tsx index 9dbb3e84f..65173ab92 100644 --- a/apps/web/app/(app)/reply-zero/page.tsx +++ b/apps/web/app/(app)/reply-zero/page.tsx @@ -26,10 +26,8 @@ export default async function ReplyTrackerPage(props: { }) { const searchParams = await props.searchParams; const session = await auth(); - if (!session?.user.email) redirect("/login"); - - const userId = session.user.id; - const userEmail = session.user.email; + const email = session?.user.email; + if (!email) redirect("/login"); const cookieStore = await cookies(); const viewedOnboarding = @@ -38,13 +36,16 @@ export default async function ReplyTrackerPage(props: { if (!viewedOnboarding) redirect("/reply-zero/onboarding"); const trackerRule = await prisma.rule.findFirst({ - where: { userId, actions: { some: { type: ActionType.TRACK_THREAD } } }, + where: { + emailAccountId: email, + actions: { some: { type: ActionType.TRACK_THREAD } }, + }, select: { id: true }, }); if (!trackerRule) redirect("/reply-zero/onboarding"); - const isAnalyzing = await isAnalyzingReplyTracker(userId); + const isAnalyzing = await isAnalyzingReplyTracker({ email }); const page = Number(searchParams.page || "1"); const timeRange = searchParams.timeRange || "all"; @@ -96,8 +97,7 @@ export default async function ReplyTrackerPage(props: { diff --git a/apps/web/app/api/user/draft-actions/route.ts b/apps/web/app/api/user/draft-actions/route.ts index db74f8cf7..64e6f5dad 100644 --- a/apps/web/app/api/user/draft-actions/route.ts +++ b/apps/web/app/api/user/draft-actions/route.ts @@ -8,19 +8,19 @@ export type DraftActionsResponse = Awaited>; export const GET = withError(async () => { const session = await auth(); - const userId = session?.user.id; - if (!userId) + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - const response = await getData(userId); + const response = await getData({ email }); return NextResponse.json(response); }); -async function getData(userId: string) { +async function getData({ email }: { email: string }) { const executedActions = await prisma.executedAction.findMany({ where: { - executedRule: { userId }, + executedRule: { emailAccountId: email }, type: ActionType.DRAFT_EMAIL, }, select: { diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index ccb0ff54b..11360cacd 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -16,13 +16,14 @@ export const createGroupAction = withActionInstrumentation( "createGroup", async (unsafeData: CreateGroupBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { error, data } = createGroupBody.safeParse(unsafeData); if (error) return { error: error.message }; const rule = await prisma.rule.findUnique({ - where: { id: data.ruleId, userId: session.user.id }, + where: { id: data.ruleId, emailAccountId: email }, select: { name: true, groupId: true }, }); if (rule?.groupId) return { groupId: rule.groupId }; @@ -31,7 +32,7 @@ export const createGroupAction = withActionInstrumentation( const group = await prisma.group.create({ data: { name: rule.name, - userId: session.user.id, + emailAccountId: email, rule: { connect: { id: data.ruleId }, }, @@ -46,7 +47,8 @@ export const addGroupItemAction = withActionInstrumentation( "addGroupItem", async (unsafeData: AddGroupItemBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { error, data } = addGroupItemBody.safeParse(unsafeData); if (error) return { error: error.message }; @@ -55,7 +57,7 @@ export const addGroupItemAction = withActionInstrumentation( where: { id: data.groupId }, }); if (!group) return { error: "Group not found" }; - if (group.userId !== session.user.id) + if (group.emailAccountId !== email) return { error: "You don't have permission to add items to this group" }; await addGroupItem(data); diff --git a/apps/web/utils/actions/permissions.ts b/apps/web/utils/actions/permissions.ts index bc8f66bc5..f90a6d76b 100644 --- a/apps/web/utils/actions/permissions.ts +++ b/apps/web/utils/actions/permissions.ts @@ -60,10 +60,13 @@ export const adminCheckPermissionsAction = withActionInstrumentation( if (!isAdmin(session.user.email)) return { error: "Not admin" }; try { - const account = await prisma.account.findFirst({ - where: { user: { email }, provider: "google" }, - select: { access_token: true, refresh_token: true }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, + select: { + account: { select: { access_token: true, refresh_token: true } }, + }, }); + const account = emailAccount?.account; if (!account) return { error: "No account found" }; const token = await getGmailAccessToken({ diff --git a/apps/web/utils/actions/reply-tracking.ts b/apps/web/utils/actions/reply-tracking.ts index 98a0db385..e3eb3ea6e 100644 --- a/apps/web/utils/actions/reply-tracking.ts +++ b/apps/web/utils/actions/reply-tracking.ts @@ -60,26 +60,26 @@ export const resolveThreadTrackerAction = withActionInstrumentation( "resolveThreadTracker", async (unsafeData: ResolveThreadTrackerBody) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, success, error } = resolveThreadTrackerSchema.safeParse(unsafeData); if (!success) return { error: error.message }; - await startAnalyzingReplyTracker(userId).catch((error) => { + await startAnalyzingReplyTracker({ email }).catch((error) => { logger.error("Error starting Reply Zero analysis", { error }); }); await prisma.threadTracker.updateMany({ where: { threadId: data.threadId, - userId, + emailAccountId: email, }, data: { resolved: data.resolved }, }); - await stopAnalyzingReplyTracker(userId).catch((error) => { + await stopAnalyzingReplyTracker({ email }).catch((error) => { logger.error("Error stopping Reply Zero analysis", { error }); }); diff --git a/apps/web/utils/redis/reply-tracker-analyzing.ts b/apps/web/utils/redis/reply-tracker-analyzing.ts index 5a9ff8d3e..4f96c239f 100644 --- a/apps/web/utils/redis/reply-tracker-analyzing.ts +++ b/apps/web/utils/redis/reply-tracker-analyzing.ts @@ -1,22 +1,22 @@ import { redis } from "@/utils/redis"; -function getKey(userId: string) { - return `reply-tracker:analyzing:${userId}`; +function getKey(email: string) { + return `reply-tracker:analyzing:${email}`; } -export async function startAnalyzingReplyTracker(userId: string) { - const key = getKey(userId); +export async function startAnalyzingReplyTracker({ email }: { email: string }) { + const key = getKey(email); // expire in 5 minutes await redis.set(key, "true", { ex: 5 * 60 }); } -export async function stopAnalyzingReplyTracker(userId: string) { - const key = getKey(userId); +export async function stopAnalyzingReplyTracker({ email }: { email: string }) { + const key = getKey(email); await redis.del(key); } -export async function isAnalyzingReplyTracker(userId: string) { - const key = getKey(userId); +export async function isAnalyzingReplyTracker({ email }: { email: string }) { + const key = getKey(email); const result = await redis.get(key); return result === "true"; } diff --git a/apps/web/utils/reply-tracker/draft-tracking.ts b/apps/web/utils/reply-tracker/draft-tracking.ts index 50d6be15c..0a6f9676f 100644 --- a/apps/web/utils/reply-tracker/draft-tracking.ts +++ b/apps/web/utils/reply-tracker/draft-tracking.ts @@ -43,7 +43,7 @@ export async function trackSentDraftStatus({ const executedAction = await prisma.executedAction.findFirst({ where: { executedRule: { - userId: userId, + emailAccountId: user.email, threadId: threadId, }, type: ActionType.DRAFT_EMAIL, @@ -134,14 +134,14 @@ export async function trackSentDraftStatus({ */ export async function cleanupThreadAIDrafts({ threadId, - userId, + email, gmail, }: { threadId: string; - userId: string; + email: string; gmail: gmail_v1.Gmail; }) { - const loggerOptions = { userId, threadId }; + const loggerOptions = { email, threadId }; logger.info("Starting cleanup of old AI drafts for thread", loggerOptions); try { @@ -149,7 +149,7 @@ export async function cleanupThreadAIDrafts({ const potentialDraftsToClean = await prisma.executedAction.findMany({ where: { executedRule: { - userId: userId, + emailAccountId: email, threadId: threadId, }, type: ActionType.DRAFT_EMAIL, From 4de18e3005ea2f3813c51f5b1266b89a5317fe5a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 02:08:26 +0300 Subject: [PATCH 006/176] more --- apps/web/utils/actions/group.ts | 5 +++-- apps/web/utils/actions/unsubscriber.ts | 8 ++++---- apps/web/utils/ai/assistant/process-user-request.ts | 2 +- apps/web/utils/group/group-item.ts | 8 +++++--- apps/web/utils/reply-tracker/check-previous-emails.ts | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index 11360cacd..6e98b30b8 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -70,9 +70,10 @@ export const deleteGroupItemAction = withActionInstrumentation( "deleteGroupItem", async (id: string) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; - await deleteGroupItem({ id, userId: session.user.id }); + await deleteGroupItem({ id, email }); revalidatePath("/automation"); }, diff --git a/apps/web/utils/actions/unsubscriber.ts b/apps/web/utils/actions/unsubscriber.ts index 16de31b06..38c732627 100644 --- a/apps/web/utils/actions/unsubscriber.ts +++ b/apps/web/utils/actions/unsubscriber.ts @@ -13,7 +13,8 @@ export const setNewsletterStatusAction = withActionInstrumentation( "setNewsletterStatus", async (unsafeData: SetNewsletterStatusBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const userEmail = session?.user.email; + if (!userEmail) return { error: "Not logged in" }; const { data, success, error } = setNewsletterStatusBody.safeParse(unsafeData); @@ -21,17 +22,16 @@ export const setNewsletterStatusAction = withActionInstrumentation( const { newsletterEmail, status } = data; - const userId = session.user.id; const email = extractEmailAddress(newsletterEmail); return await prisma.newsletter.upsert({ where: { - email_userId: { email, userId }, + email_emailAccountId: { email, emailAccountId: userEmail }, }, create: { status, email, - user: { connect: { id: userId } }, + emailAccountId: userEmail, }, update: { status }, }); diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 0e7977c0d..504f59a86 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -354,7 +354,7 @@ ${senderCategory || "No category"} try { await deleteGroupItem({ id: groupItem.id, - userId: user.userId, + email: user.email, }); } catch (error) { const message = diff --git a/apps/web/utils/group/group-item.ts b/apps/web/utils/group/group-item.ts index fdcee6c94..adcf61e77 100644 --- a/apps/web/utils/group/group-item.ts +++ b/apps/web/utils/group/group-item.ts @@ -20,10 +20,12 @@ export async function addGroupItem(data: { export async function deleteGroupItem({ id, - userId, + email, }: { id: string; - userId: string; + email: string; }) { - await prisma.groupItem.delete({ where: { id, group: { userId } } }); + await prisma.groupItem.delete({ + where: { id, group: { emailAccountId: email } }, + }); } diff --git a/apps/web/utils/reply-tracker/check-previous-emails.ts b/apps/web/utils/reply-tracker/check-previous-emails.ts index e2f264224..11d6e7936 100644 --- a/apps/web/utils/reply-tracker/check-previous-emails.ts +++ b/apps/web/utils/reply-tracker/check-previous-emails.ts @@ -46,8 +46,8 @@ export async function processPreviousSentEmails( const isProcessed = await prisma.executedRule.findUnique({ where: { - unique_user_thread_message: { - userId: user.userId, + unique_emailAccount_thread_message: { + emailAccountId: user.email, threadId: latestMessage.threadId, messageId: latestMessage.id, }, From 2fc34248325f22a8faa889aa3f13dc9c16278171 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 02:32:18 +0300 Subject: [PATCH 007/176] move over more --- apps/web/app/(app)/reply-zero/NeedsAction.tsx | 4 +- apps/web/app/api/google/threads/controller.ts | 2 +- .../google/webhook/process-history-item.ts | 10 +++-- apps/web/app/api/unsubscribe/route.ts | 11 +++-- .../senders/uncategorized/get-senders.ts | 6 +-- .../get-uncategorized-senders.ts | 8 ++-- .../categorize/senders/uncategorized/route.ts | 2 +- apps/web/app/api/user/stats/helpers.ts | 6 +-- .../app/api/user/stats/newsletters/helpers.ts | 8 +++- .../app/api/user/stats/newsletters/route.ts | 2 +- .../app/api/user/stats/recipients/route.ts | 14 +++--- apps/web/app/api/user/stats/senders/route.ts | 16 +++---- .../user/stats/tinybird/load/load-emails.ts | 20 ++++----- .../app/api/user/stats/tinybird/load/route.ts | 6 +-- apps/web/utils/actions/categorize.ts | 2 +- .../utils/ai/choose-rule/draft-management.ts | 4 +- .../utils/ai/choose-rule/match-rules.test.ts | 32 +++++++------- apps/web/utils/ai/choose-rule/match-rules.ts | 31 +++++++------ apps/web/utils/ai/choose-rule/run-rules.ts | 43 +++++++++++-------- apps/web/utils/group/find-matching-group.ts | 4 +- 20 files changed, 123 insertions(+), 108 deletions(-) diff --git a/apps/web/app/(app)/reply-zero/NeedsAction.tsx b/apps/web/app/(app)/reply-zero/NeedsAction.tsx index 30bd0a7d7..e1f2a5448 100644 --- a/apps/web/app/(app)/reply-zero/NeedsAction.tsx +++ b/apps/web/app/(app)/reply-zero/NeedsAction.tsx @@ -17,7 +17,7 @@ export async function NeedsAction({ isAnalyzing: boolean; }) { const { trackers, totalPages } = await getPaginatedThreadTrackers({ - userId, + email: userEmail, type: ThreadTrackerType.NEEDS_ACTION, page, timeRange, @@ -26,7 +26,7 @@ export async function NeedsAction({ return ( { const offset = Number.parseInt(url.searchParams.get("offset") || "0"); const result = await getUncategorizedSenders({ - userId: user.id, + emailAccountId: user.email, offset, }); diff --git a/apps/web/app/api/user/stats/helpers.ts b/apps/web/app/api/user/stats/helpers.ts index 16a10f16d..94ae7fc93 100644 --- a/apps/web/app/api/user/stats/helpers.ts +++ b/apps/web/app/api/user/stats/helpers.ts @@ -14,13 +14,13 @@ interface EmailFieldStatsResult { * Get detailed email stats for a specific field */ export async function getEmailFieldStats({ - userId, + emailAccountId, fromDate, toDate, field, isSent, }: { - userId: string; + emailAccountId: string; fromDate?: number | null; toDate?: number | null; field: EmailField; @@ -31,7 +31,7 @@ export async function getEmailFieldStats({ const emailsCount = await prisma.emailMessage.groupBy({ by: [field], where: { - userId, + emailAccountId, sent: isSent, date: { gte: dateRange.fromDate ? new Date(dateRange.fromDate) : undefined, diff --git a/apps/web/app/api/user/stats/newsletters/helpers.ts b/apps/web/app/api/user/stats/newsletters/helpers.ts index f5ec16970..8f57c56ee 100644 --- a/apps/web/app/api/user/stats/newsletters/helpers.ts +++ b/apps/web/app/api/user/stats/newsletters/helpers.ts @@ -29,9 +29,13 @@ export function findAutoArchiveFilter( }); } -export async function findNewsletterStatus(userId: string) { +export async function findNewsletterStatus({ + emailAccountId, +}: { + emailAccountId: string; +}) { const userNewsletters = await prisma.newsletter.findMany({ - where: { userId }, + where: { emailAccountId }, select: { email: true, status: true }, }); return userNewsletters; diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index a6c470244..66844c646 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -78,7 +78,7 @@ async function getNewslettersTinybird( ...types, }), getAutoArchiveFilters(), - findNewsletterStatus(options.userId), + findNewsletterStatus({ emailAccountId: options.ownerEmail }), ]); const newsletters = newsletterCounts.map((email: NewsletterCountResult) => { diff --git a/apps/web/app/api/user/stats/recipients/route.ts b/apps/web/app/api/user/stats/recipients/route.ts index 70530b696..24d8caee9 100644 --- a/apps/web/app/api/user/stats/recipients/route.ts +++ b/apps/web/app/api/user/stats/recipients/route.ts @@ -52,7 +52,7 @@ async function getRecipients({ } async function getRecipientStatistics( - options: RecipientStatsQuery & { userId: string }, + options: RecipientStatsQuery & { emailAccountId: string }, ): Promise { const [mostReceived] = await Promise.all([getMostSentTo(options)]); @@ -70,14 +70,14 @@ async function getRecipientStatistics( * Get most sent to recipients by email address */ async function getMostSentTo({ - userId, + emailAccountId, fromDate, toDate, }: RecipientStatsQuery & { - userId: string; + emailAccountId: string; }) { return getEmailFieldStats({ - userId, + emailAccountId, fromDate, toDate, field: "to", @@ -87,8 +87,8 @@ async function getMostSentTo({ export const GET = withError(async (request) => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const emailAccountId = session?.user.email; + if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); const { searchParams } = new URL(request.url); const query = recipientStatsQuery.parse({ @@ -98,7 +98,7 @@ export const GET = withError(async (request) => { const result = await getRecipientStatistics({ ...query, - userId: session.user.id, + emailAccountId, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/senders/route.ts b/apps/web/app/api/user/stats/senders/route.ts index db9ccd68f..20e9032ba 100644 --- a/apps/web/app/api/user/stats/senders/route.ts +++ b/apps/web/app/api/user/stats/senders/route.ts @@ -19,7 +19,7 @@ export interface SendersResponse { * Get sender statistics from database */ async function getSenderStatistics( - options: SenderStatsQuery & { userId: string }, + options: SenderStatsQuery & { emailAccountId: string }, ): Promise { const [mostReceived, mostReceivedDomains] = await Promise.all([ getMostReceivedFrom(options), @@ -46,14 +46,14 @@ async function getSenderStatistics( * Get most received from senders by email address */ async function getMostReceivedFrom({ - userId, + emailAccountId, fromDate, toDate, }: SenderStatsQuery & { - userId: string; + emailAccountId: string; }) { return getEmailFieldStats({ - userId, + emailAccountId, fromDate, toDate, field: "from", @@ -65,14 +65,14 @@ async function getMostReceivedFrom({ * Get most received from senders by domain */ async function getDomainsMostReceivedFrom({ - userId, + emailAccountId, fromDate, toDate, }: SenderStatsQuery & { - userId: string; + emailAccountId: string; }) { return getEmailFieldStats({ - userId, + emailAccountId, fromDate, toDate, field: "fromDomain", @@ -93,7 +93,7 @@ export const GET = withError(async (request) => { const result = await getSenderStatistics({ ...query, - userId: session.user.id, + emailAccountId: session.user.email, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/tinybird/load/load-emails.ts b/apps/web/app/api/user/stats/tinybird/load/load-emails.ts index 705db9950..939a8dbb4 100644 --- a/apps/web/app/api/user/stats/tinybird/load/load-emails.ts +++ b/apps/web/app/api/user/stats/tinybird/load/load-emails.ts @@ -17,11 +17,11 @@ const logger = createScopedLogger("Load Emails"); export async function loadEmails( { - userId, + emailAccountId, gmail, accessToken, }: { - userId: string; + emailAccountId: string; gmail: gmail_v1.Gmail; accessToken: string; }, @@ -31,7 +31,7 @@ export async function loadEmails( let pages = 0; const newestEmailSaved = await prisma.emailMessage.findFirst({ - where: { userId }, + where: { emailAccountId }, orderBy: { date: "desc" }, }); @@ -41,7 +41,7 @@ export async function loadEmails( while (pages < MAX_PAGES) { logger.info("After Page", { pages }); const res = await saveBatch({ - userId, + emailAccountId, gmail, accessToken, nextPageToken, @@ -60,7 +60,7 @@ export async function loadEmails( if (!body.loadBefore || !newestEmailSaved) return { pages }; const oldestEmailSaved = await prisma.emailMessage.findFirst({ - where: { userId }, + where: { emailAccountId }, orderBy: { date: "asc" }, }); @@ -73,7 +73,7 @@ export async function loadEmails( while (pages < MAX_PAGES) { logger.info("Before Page", { pages }); const res = await saveBatch({ - userId, + emailAccountId, gmail, accessToken, nextPageToken, @@ -93,14 +93,14 @@ export async function loadEmails( } async function saveBatch({ - userId, + emailAccountId, gmail, accessToken, nextPageToken, before, after, }: { - userId: string; + emailAccountId: string; gmail: gmail_v1.Gmail; accessToken: string; nextPageToken?: string; @@ -140,7 +140,7 @@ async function saveBatch({ const date = internalDateToDate(m.internalDate); if (!date) { logger.error("No date for email", { - userId, + email: emailAccountId, messageId: m.id, date: m.internalDate, }); @@ -159,7 +159,7 @@ async function saveBatch({ sent: !!m.labelIds?.includes(GmailLabel.SENT), draft: !!m.labelIds?.includes(GmailLabel.DRAFT), inbox: !!m.labelIds?.includes(GmailLabel.INBOX), - userId, + emailAccountId, }; }) .filter(isDefined); diff --git a/apps/web/app/api/user/stats/tinybird/load/route.ts b/apps/web/app/api/user/stats/tinybird/load/route.ts index 81a7938eb..316cc0b0c 100644 --- a/apps/web/app/api/user/stats/tinybird/load/route.ts +++ b/apps/web/app/api/user/stats/tinybird/load/route.ts @@ -11,8 +11,8 @@ export type LoadTinybirdEmailsResponse = Awaited>; export const POST = withError(async (request: Request) => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); const json = await request.json(); const body = loadTinybirdEmailsBody.parse(json); @@ -24,7 +24,7 @@ export const POST = withError(async (request: Request) => { const result = await loadEmails( { - userId: session.user.id, + emailAccountId: email, gmail, accessToken: token.token, }, diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 70fadeb35..4d2de0b99 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -49,7 +49,7 @@ export const bulkCategorizeSendersAction = withActionInstrumentation( async function getUncategorizedSenders(offset: number) { const result = await getSenders({ - userId: user.id, + emailAccountId: user.email, limit: LIMIT, offset, }); diff --git a/apps/web/utils/ai/choose-rule/draft-management.ts b/apps/web/utils/ai/choose-rule/draft-management.ts index c42a84b7f..ae842ffe7 100644 --- a/apps/web/utils/ai/choose-rule/draft-management.ts +++ b/apps/web/utils/ai/choose-rule/draft-management.ts @@ -14,7 +14,7 @@ export async function handlePreviousDraftDeletion({ logger, }: { gmail: gmail_v1.Gmail; - executedRule: Pick; + executedRule: Pick; logger: Logger; }) { try { @@ -23,7 +23,7 @@ export async function handlePreviousDraftDeletion({ where: { executedRule: { threadId: executedRule.threadId, // Match threadId - userId: executedRule.userId, // Match userId for safety + emailAccountId: executedRule.emailAccountId, // Match emailAccountId for safety }, type: ActionType.DRAFT_EMAIL, draftId: { not: null }, // Ensure it has a draftId diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index dd9ae7585..ce96a73c0 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -108,7 +108,7 @@ describe("findMatchingRule", () => { headers: getHeaders({ from: "test@example.com" }), }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe("Matched static conditions"); @@ -122,7 +122,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe("Matched static conditions"); @@ -136,7 +136,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -161,7 +161,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); @@ -180,7 +180,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -199,7 +199,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -235,7 +235,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); @@ -265,7 +265,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBeDefined(); @@ -296,7 +296,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule).toBeUndefined(); expect(result.reason).toBeDefined(); @@ -318,7 +318,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -339,7 +339,7 @@ describe("findMatchingRule", () => { const message = getMessage(); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -381,7 +381,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -421,7 +421,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toContain("test@example.com"); @@ -462,7 +462,7 @@ describe("findMatchingRule", () => { }); const user = getUser(); - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); // Should match the first rule only expect(result.rule?.id).toBe("rule1"); @@ -510,7 +510,7 @@ function getCategory(overrides: Partial = {}): Category { name: "category", createdAt: new Date(), updatedAt: new Date(), - userId: "userId", + emailAccountId: "emailAccountId", description: null, ...overrides, }; @@ -526,7 +526,7 @@ function getGroup( name: "group", createdAt: new Date(), updatedAt: new Date(), - userId: "userId", + emailAccountId: "emailAccountId", prompt: null, items: [], rule: null, diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 5ad294de7..4970d5d2d 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -75,20 +75,20 @@ async function findPotentialMatchingRules({ // groups singleton let groups: Awaited>; // only load once and only when needed - async function getGroups(userId: string) { - if (!groups) groups = await getGroupsWithRules(userId); + async function getGroups({ email }: { email: string }) { + if (!groups) groups = await getGroupsWithRules({ email }); return groups; } // sender singleton let sender: { categoryId: string | null } | null | undefined; - async function getSender(userId: string) { + async function getSender({ email }: { email: string }) { if (typeof sender === "undefined") { sender = await prisma.newsletter.findUnique({ where: { - email_userId: { + email_emailAccountId: { email: extractEmailAddress(message.headers.from), - userId, + emailAccountId: email, }, }, select: { categoryId: true }, @@ -112,7 +112,7 @@ async function findPotentialMatchingRules({ if (rule.groupId) { const { matchingItem, group } = await matchesGroupRule( rule, - await getGroups(rule.userId), + await getGroups({ email: rule.emailAccountId }), message, ); if (matchingItem) { @@ -149,7 +149,7 @@ async function findPotentialMatchingRules({ if (conditionTypes.CATEGORY) { const matchedCategory = await matchesCategoryRule( rule, - await getSender(rule.userId), + await getSender({ email: rule.emailAccountId }), ); if (matchedCategory) { unmatchedConditions.delete(ConditionType.CATEGORY); @@ -203,12 +203,17 @@ function getMatchReason(matchReasons?: MatchReason[]): string | undefined { .join(", "); } -export async function findMatchingRule( - rules: RuleWithActionsAndCategories[], - message: ParsedMessage, - user: UserEmailWithAI, - gmail: gmail_v1.Gmail, -) { +export async function findMatchingRule({ + rules, + message, + user, + gmail, +}: { + rules: RuleWithActionsAndCategories[]; + message: ParsedMessage; + user: UserEmailWithAI; + gmail: gmail_v1.Gmail; +}) { const result = await findMatchingRuleWithReasons(rules, message, user, gmail); return { ...result, diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index cee0a155b..652b4fec4 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -45,7 +45,7 @@ export async function runRules({ user: UserEmailWithAI; isTest: boolean; }): Promise { - const result = await findMatchingRule(rules, message, user, gmail); + const result = await findMatchingRule({ rules, message, user, gmail }); analyzeSenderPatternIfAiMatch({ isTest, @@ -68,7 +68,7 @@ export async function runRules({ ); } else { await saveSkippedExecutedRule({ - userId: user.userId, + emailAccountId: user.email, threadId: message.threadId, messageId: message.id, reason: result.reason, @@ -99,7 +99,7 @@ async function executeMatchedRule( ? undefined : await saveExecutedRule( { - userId: user.userId, + emailAccountId: user.email, threadId: message.threadId, messageId: message.id, }, @@ -115,7 +115,7 @@ async function executeMatchedRule( if (shouldExecute) { await executeAct({ gmail, - userEmail: user.email || "", + userEmail: user.email, executedRule, message, }); @@ -131,12 +131,12 @@ async function executeMatchedRule( } async function saveSkippedExecutedRule({ - userId, + emailAccountId, threadId, messageId, reason, }: { - userId: string; + emailAccountId: string; threadId: string; messageId: string; reason?: string; @@ -147,11 +147,11 @@ async function saveSkippedExecutedRule({ automated: true, reason, status: ExecutedRuleStatus.SKIPPED, - user: { connect: { id: userId } }, + emailAccount: { connect: { email: emailAccountId } }, }; await upsertExecutedRule({ - userId, + emailAccountId, threadId, messageId, data, @@ -160,11 +160,11 @@ async function saveSkippedExecutedRule({ async function saveExecutedRule( { - userId, + emailAccountId, threadId, messageId, }: { - userId: string; + emailAccountId: string; threadId: string; messageId: string; }, @@ -190,19 +190,24 @@ async function saveExecutedRule( status: ExecutedRuleStatus.PENDING, reason, rule: rule?.id ? { connect: { id: rule.id } } : undefined, - user: { connect: { id: userId } }, + emailAccount: { connect: { email: emailAccountId } }, }; - return await upsertExecutedRule({ userId, threadId, messageId, data }); + return await upsertExecutedRule({ + emailAccountId, + threadId, + messageId, + data, + }); } async function upsertExecutedRule({ - userId, + emailAccountId, threadId, messageId, data, }: { - userId: string; + emailAccountId: string; threadId: string; messageId: string; data: Prisma.ExecutedRuleCreateInput; @@ -210,8 +215,8 @@ async function upsertExecutedRule({ try { return await prisma.executedRule.upsert({ where: { - unique_user_thread_message: { - userId, + unique_emailAccount_thread_message: { + emailAccountId, threadId, messageId, }, @@ -228,14 +233,14 @@ async function upsertExecutedRule({ // Unique constraint violation, ignore the error // May be due to a race condition? logger.info("Ignored duplicate entry for ExecutedRule", { - userId, + email: emailAccountId, threadId, messageId, }); return await prisma.executedRule.findUnique({ where: { - unique_user_thread_message: { - userId, + unique_emailAccount_thread_message: { + emailAccountId, threadId, messageId, }, diff --git a/apps/web/utils/group/find-matching-group.ts b/apps/web/utils/group/find-matching-group.ts index b02ff6508..4bc29adcb 100644 --- a/apps/web/utils/group/find-matching-group.ts +++ b/apps/web/utils/group/find-matching-group.ts @@ -4,9 +4,9 @@ import type { ParsedMessage } from "@/utils/types"; import { type GroupItem, GroupItemType } from "@prisma/client"; type GroupsWithRules = Awaited>; -export async function getGroupsWithRules(userId: string) { +export async function getGroupsWithRules({ email }: { email: string }) { return prisma.group.findMany({ - where: { userId, rule: { isNot: null } }, + where: { emailAccountId: email, rule: { isNot: null } }, include: { items: true, rule: { include: { actions: true } } }, }); } From 9d9a25510f0f0863d25ca6320b0a2a1316c9ce00 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 02:46:41 +0300 Subject: [PATCH 008/176] user email or --- .../ai/assistant/process-user-request.ts | 2 +- .../ai-categorize-senders.ts | 2 +- .../ai-categorize-single-sender.ts | 2 +- .../utils/ai/choose-rule/ai-choose-args.ts | 2 +- .../utils/ai/choose-rule/ai-choose-rule.ts | 2 +- .../ai-detect-recurring-pattern.ts | 2 +- .../utils/ai/clean/ai-clean-select-labels.ts | 2 +- apps/web/utils/ai/clean/ai-clean.ts | 2 +- .../example-matches/find-example-matches.ts | 2 +- apps/web/utils/ai/group/create-group.ts | 4 +-- .../utils/ai/reply/check-if-needs-reply.ts | 2 +- .../ai/rule/generate-prompt-on-delete-rule.ts | 2 +- .../ai/rule/generate-prompt-on-update-rule.ts | 2 +- .../utils/ai/rule/generate-rules-prompt.ts | 2 +- apps/web/utils/cold-email/is-cold-email.ts | 25 ++++++++++++------- 15 files changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 504f59a86..3a98fb69d 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -580,7 +580,7 @@ ${senderCategory || "No category"} }, maxSteps: 5, label: "Fix Rule", - userEmail: user.email || "", + userEmail: user.email, }); const toolCalls = result.steps.flatMap((step) => step.toolCalls); diff --git a/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts index 25403a078..82addf013 100644 --- a/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts +++ b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts @@ -94,7 +94,7 @@ ${formatCategoriesForPrompt(categories)} system, prompt, schema: categorizeSendersSchema, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Categorize senders bulk", }); diff --git a/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts b/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts index 752b9ad6e..74dfdcdab 100644 --- a/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts +++ b/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts @@ -62,7 +62,7 @@ ${formatCategoriesForPrompt(categories)} system, prompt, schema: categorizeSenderSchema, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Categorize sender", }); diff --git a/apps/web/utils/ai/choose-rule/ai-choose-args.ts b/apps/web/utils/ai/choose-rule/ai-choose-args.ts index 455532a18..195bf15c8 100644 --- a/apps/web/utils/ai/choose-rule/ai-choose-args.ts +++ b/apps/web/utils/ai/choose-rule/ai-choose-args.ts @@ -91,7 +91,7 @@ export async function aiGenerateArgs({ }, }, label: "Args for rule", - userEmail: user.email || "", + userEmail: user.email, }), { retryIf: (error: unknown) => InvalidToolArgumentsError.isInstance(error), diff --git a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts index e8712a323..8d93b6160 100644 --- a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts +++ b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts @@ -101,7 +101,7 @@ ${emailSection} ruleName: z.string(), noMatchFound: z.boolean().optional(), }), - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Choose rule", }); diff --git a/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts b/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts index e9038ea2a..5fa8351a9 100644 --- a/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts +++ b/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts @@ -103,7 +103,7 @@ ${stringifyEmail(email, 500)} system, prompt, schema, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Detect recurring pattern", }); diff --git a/apps/web/utils/ai/clean/ai-clean-select-labels.ts b/apps/web/utils/ai/clean/ai-clean-select-labels.ts index 811651491..57eab7e8c 100644 --- a/apps/web/utils/ai/clean/ai-clean-select-labels.ts +++ b/apps/web/utils/ai/clean/ai-clean-select-labels.ts @@ -35,7 +35,7 @@ ${instructions} system, prompt, schema, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Clean - Select Labels", }); diff --git a/apps/web/utils/ai/clean/ai-clean.ts b/apps/web/utils/ai/clean/ai-clean.ts index 8c04e1ca6..f3c3efe74 100644 --- a/apps/web/utils/ai/clean/ai-clean.ts +++ b/apps/web/utils/ai/clean/ai-clean.ts @@ -97,7 +97,7 @@ The current date is ${currentDate}. system, prompt, schema, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Clean", }); diff --git a/apps/web/utils/ai/example-matches/find-example-matches.ts b/apps/web/utils/ai/example-matches/find-example-matches.ts index a805cefb1..8d6fdbd2e 100644 --- a/apps/web/utils/ai/example-matches/find-example-matches.ts +++ b/apps/web/utils/ai/example-matches/find-example-matches.ts @@ -105,7 +105,7 @@ Remember, precision is crucial - only include matches you are absolutely sure ab parameters: findExampleMatchesSchema, }, }, - userEmail: user.email || "", + userEmail: user.email, label: "Find example matches", }); diff --git a/apps/web/utils/ai/group/create-group.ts b/apps/web/utils/ai/group/create-group.ts index 8779f0fec..2a26f2f8b 100644 --- a/apps/web/utils/ai/group/create-group.ts +++ b/apps/web/utils/ai/group/create-group.ts @@ -100,7 +100,7 @@ Key guidelines: parameters: generateGroupItemsSchema, }, }, - userEmail: user.email || "", + userEmail: user.email, label: "Create group", }); @@ -171,7 +171,7 @@ Guidelines: parameters: verifyGroupItemsSchema, }, }, - userEmail: user.email || "", + userEmail: user.email, label: "Verify group criteria", }); diff --git a/apps/web/utils/ai/reply/check-if-needs-reply.ts b/apps/web/utils/ai/reply/check-if-needs-reply.ts index 6a44bdf66..9f65a78a9 100644 --- a/apps/web/utils/ai/reply/check-if-needs-reply.ts +++ b/apps/web/utils/ai/reply/check-if-needs-reply.ts @@ -70,7 +70,7 @@ Decide if the message we are sending needs a reply. system, prompt, schema, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Check if needs reply", }); diff --git a/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts b/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts index d69e54e3e..0b7ce8cdf 100644 --- a/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts +++ b/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts @@ -57,7 +57,7 @@ ${deletedRulePrompt} prompt, system, schema: parameters, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Update prompt on delete rule", }); diff --git a/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts b/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts index aff0ad6a9..89ed9c220 100644 --- a/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts +++ b/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts @@ -62,7 +62,7 @@ ${updatedRulePrompt} prompt, system, schema: parameters, - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Update prompt on update rule", }); diff --git a/apps/web/utils/ai/rule/generate-rules-prompt.ts b/apps/web/utils/ai/rule/generate-rules-prompt.ts index adfd991de..631c8dc36 100644 --- a/apps/web/utils/ai/rule/generate-rules-prompt.ts +++ b/apps/web/utils/ai/rule/generate-rules-prompt.ts @@ -118,7 +118,7 @@ Your response should only include the list of general rules. Aim for 3-10 broadl parameters: hasSnippets ? parametersSnippets : parameters, }, }, - userEmail: user.email || "", + userEmail: user.email, label: "Generate rules prompt", }); diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index 864ee6c19..cd215475c 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -34,7 +34,6 @@ export async function isColdEmail({ aiReason?: string | null; }> { const loggerOptions = { - userId: user.userId, email: user.email, threadId: email.threadId, messageId: email.id, @@ -45,7 +44,7 @@ export async function isColdEmail({ // Check if we marked it as a cold email already const isColdEmailer = await isKnownColdEmailSender({ from: email.from, - userId: user.userId, + emailAccountId: user.email, }); if (isColdEmailer) { @@ -87,14 +86,17 @@ export async function isColdEmail({ async function isKnownColdEmailSender({ from, - userId, + emailAccountId, }: { from: string; - userId: string; + emailAccountId: string; }) { const coldEmail = await prisma.coldEmail.findUnique({ where: { - userId_fromEmail: { userId, fromEmail: from }, + emailAccountId_fromEmail: { + emailAccountId, + fromEmail: from, + }, status: ColdEmailStatus.AI_LABELED_COLD, }, select: { id: true }, @@ -141,7 +143,7 @@ ${stringifyEmail(email, 500)} coldEmail: z.boolean(), reason: z.string(), }), - userEmail: user.email || "", + userEmail: user.email, usageLabel: "Cold email check", }); @@ -171,12 +173,17 @@ export async function blockColdEmail(options: { const { gmail, email, user, aiReason } = options; await prisma.coldEmail.upsert({ - where: { userId_fromEmail: { userId: user.userId, fromEmail: email.from } }, + where: { + emailAccountId_fromEmail: { + emailAccountId: user.email, + fromEmail: email.from, + }, + }, update: { status: ColdEmailStatus.AI_LABELED_COLD }, create: { status: ColdEmailStatus.AI_LABELED_COLD, fromEmail: email.from, - userId: user.userId, + emailAccountId: user.email, reason: aiReason, messageId: email.id, threadId: email.threadId, @@ -194,7 +201,7 @@ export async function blockColdEmail(options: { key: "cold_email", }); if (!coldEmailLabel?.id) - logger.error("No gmail label id", { userId: user.userId }); + logger.error("No gmail label id", { emailAccountId: user.email }); const shouldArchive = user.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || From 5dcef89cfe1332a99ef6630658c6dbb88652c64e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 02:51:27 +0300 Subject: [PATCH 009/176] move over more relations --- apps/web/__tests__/ai-choose-args.test.ts | 2 +- apps/web/__tests__/ai-extract-knowledge.test.ts | 12 ++++++------ .../web/__tests__/ai-process-user-request.test.ts | 4 ++-- apps/web/app/(app)/reply-zero/onboarding/page.tsx | 10 ++++++---- .../google/webhook/block-unsubscribed-emails.ts | 9 +++++---- .../api/google/webhook/process-history-item.ts | 2 +- apps/web/app/api/knowledge/route.ts | 12 ++++-------- .../user/group/[groupId]/messages/controller.ts | 6 +++--- .../api/user/group/[groupId]/messages/route.ts | 6 +++--- .../app/api/user/group/[groupId]/rules/route.ts | 15 ++++++--------- apps/web/app/api/user/labels/route.ts | 12 +++++------- apps/web/app/api/user/rules/[id]/example/route.ts | 5 +++-- .../api/user/stats/newsletters/summary/route.ts | 12 +++++++----- 13 files changed, 52 insertions(+), 55 deletions(-) diff --git a/apps/web/__tests__/ai-choose-args.test.ts b/apps/web/__tests__/ai-choose-args.test.ts index 7b146482c..865a93ecb 100644 --- a/apps/web/__tests__/ai-choose-args.test.ts +++ b/apps/web/__tests__/ai-choose-args.test.ts @@ -185,7 +185,7 @@ function getRule( name: "Test Rule", actions, id: "r123", - userId: "userId", + emailAccountId: "emailAccountId", createdAt: new Date(), updatedAt: new Date(), automate: false, diff --git a/apps/web/__tests__/ai-extract-knowledge.test.ts b/apps/web/__tests__/ai-extract-knowledge.test.ts index 6b66b7fb0..134216bff 100644 --- a/apps/web/__tests__/ai-extract-knowledge.test.ts +++ b/apps/web/__tests__/ai-extract-knowledge.test.ts @@ -15,7 +15,7 @@ function getKnowledgeBase(): Knowledge[] { return [ { id: "1", - userId: "test-user-id", + emailAccountId: "test-user-id", title: "Instagram Sponsorship Rates", content: `For brand sponsorships on Instagram, my standard rate is $5,000 per post. This includes one main feed post with up to 3 stories. For longer term partnerships @@ -25,7 +25,7 @@ function getKnowledgeBase(): Knowledge[] { }, { id: "2", - userId: "test-user-id", + emailAccountId: "test-user-id", title: "YouTube Sponsorship Packages", content: `My YouTube sponsorship packages start at $10,000 for a 60-90 second integration. This includes one round of revisions and a draft review before posting. @@ -35,7 +35,7 @@ function getKnowledgeBase(): Knowledge[] { }, { id: "3", - userId: "test-user-id", + emailAccountId: "test-user-id", title: "TikTok Collaboration Rates", content: `For TikTok collaborations, I charge $3,000 per video. This includes concept development, filming, and editing. I typically post between 6-8pm EST @@ -45,7 +45,7 @@ function getKnowledgeBase(): Knowledge[] { }, { id: "4", - userId: "test-user-id", + emailAccountId: "test-user-id", title: "Speaking Engagements", content: `I'm available for keynote speaking at tech and marketing conferences. My speaking fee is $15,000 for in-person events and $5,000 for virtual events. @@ -56,7 +56,7 @@ function getKnowledgeBase(): Knowledge[] { }, { id: "5", - userId: "test-user-id", + emailAccountId: "test-user-id", title: "Brand Ambassador Programs", content: `For long-term brand ambassador roles, I offer quarterly packages starting at $50,000. This includes monthly content across all platforms (Instagram, YouTube, @@ -67,7 +67,7 @@ function getKnowledgeBase(): Knowledge[] { }, { id: "6", - userId: "test-user-id", + emailAccountId: "test-user-id", title: "Consulting Services", content: `I offer social media strategy consulting for brands and creators. Hourly rate is $500, with package options available: diff --git a/apps/web/__tests__/ai-process-user-request.test.ts b/apps/web/__tests__/ai-process-user-request.test.ts index 04cd8b08f..df57a0d0b 100644 --- a/apps/web/__tests__/ai-process-user-request.test.ts +++ b/apps/web/__tests__/ai-process-user-request.test.ts @@ -421,7 +421,7 @@ describe( function getRule(rule: Partial): RuleWithRelations { return { id: "1", - userId: "user1", + emailAccountId: "user1", name: "Rule name", conditionalOperator: LogicalOperator.AND, @@ -515,7 +515,7 @@ function getCategory(category: Partial): Category { description: null, createdAt: new Date(), updatedAt: new Date(), - userId: "user1", + emailAccountId: "user1", ...category, }; } diff --git a/apps/web/app/(app)/reply-zero/onboarding/page.tsx b/apps/web/app/(app)/reply-zero/onboarding/page.tsx index 6930fe452..cfac9036d 100644 --- a/apps/web/app/(app)/reply-zero/onboarding/page.tsx +++ b/apps/web/app/(app)/reply-zero/onboarding/page.tsx @@ -6,12 +6,14 @@ import { ActionType } from "@prisma/client"; export default async function OnboardingReplyTracker() { const session = await auth(); - if (!session?.user.email) redirect("/login"); - - const userId = session.user.id; + const emailAccountId = session?.user.email; + if (!emailAccountId) redirect("/login"); const trackerRule = await prisma.rule.findFirst({ - where: { userId, actions: { some: { type: ActionType.TRACK_THREAD } } }, + where: { + emailAccountId, + actions: { some: { type: ActionType.TRACK_THREAD } }, + }, select: { id: true }, }); diff --git a/apps/web/app/api/google/webhook/block-unsubscribed-emails.ts b/apps/web/app/api/google/webhook/block-unsubscribed-emails.ts index 0a340775d..aac8686c5 100644 --- a/apps/web/app/api/google/webhook/block-unsubscribed-emails.ts +++ b/apps/web/app/api/google/webhook/block-unsubscribed-emails.ts @@ -13,19 +13,19 @@ const logger = createScopedLogger("google/webhook/block-unsubscribed-emails"); export async function blockUnsubscribedEmails({ from, - userId, + emailAccountId, gmail, messageId, }: { from: string; - userId: string; + emailAccountId: string; gmail: gmail_v1.Gmail; messageId: string; }): Promise { const email = extractEmailAddress(from); const sender = await prisma.newsletter.findFirst({ where: { - userId, + emailAccountId, email, status: NewsletterStatus.UNSUBSCRIBED, }, @@ -37,7 +37,8 @@ export async function blockUnsubscribedEmails({ gmail, key: "unsubscribed", }); - if (!unsubscribeLabel?.id) logger.error("No gmail label id", { userId }); + if (!unsubscribeLabel?.id) + logger.error("No gmail label id", { emailAccountId }); await labelMessage({ gmail, diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index b98ddedaf..1a06d0053 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -123,7 +123,7 @@ export async function processHistoryItem( // check if unsubscribed const blocked = await blockUnsubscribedEmails({ from: message.headers.from, - userId: user.userId, + emailAccountId: userEmail, gmail, messageId, }); diff --git a/apps/web/app/api/knowledge/route.ts b/apps/web/app/api/knowledge/route.ts index 85b0a5b97..f8231c06f 100644 --- a/apps/web/app/api/knowledge/route.ts +++ b/apps/web/app/api/knowledge/route.ts @@ -10,16 +10,12 @@ export type GetKnowledgeResponse = { export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const emailAccountId = session?.user.email; + if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); const items = await prisma.knowledge.findMany({ - where: { - userId: session.user.id, - }, - orderBy: { - updatedAt: "desc", - }, + where: { emailAccountId }, + orderBy: { updatedAt: "desc" }, }); const result: GetKnowledgeResponse = { items }; diff --git a/apps/web/app/api/user/group/[groupId]/messages/controller.ts b/apps/web/app/api/user/group/[groupId]/messages/controller.ts index 297d1cbfc..d2d8c970c 100644 --- a/apps/web/app/api/user/group/[groupId]/messages/controller.ts +++ b/apps/web/app/api/user/group/[groupId]/messages/controller.ts @@ -23,21 +23,21 @@ export type GroupEmailsResponse = Awaited>; export async function getGroupEmails({ groupId, - userId, + emailAccountId, gmail, from, to, pageToken, }: { groupId: string; - userId: string; + emailAccountId: string; gmail: gmail_v1.Gmail; from?: Date; to?: Date; pageToken?: string; }) { const group = await prisma.group.findUnique({ - where: { id: groupId, userId }, + where: { id: groupId, emailAccountId }, include: { items: true }, }); diff --git a/apps/web/app/api/user/group/[groupId]/messages/route.ts b/apps/web/app/api/user/group/[groupId]/messages/route.ts index 778a35a73..d9e2cb3ee 100644 --- a/apps/web/app/api/user/group/[groupId]/messages/route.ts +++ b/apps/web/app/api/user/group/[groupId]/messages/route.ts @@ -6,8 +6,8 @@ import { getGmailClient } from "@/utils/gmail/client"; export const GET = withError(async (_request, { params }) => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const emailAccountId = session?.user.email; + if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Missing group id" }); @@ -16,7 +16,7 @@ export const GET = withError(async (_request, { params }) => { const { messages } = await getGroupEmails({ groupId, - userId: session.user.id, + emailAccountId, gmail, from: undefined, to: undefined, diff --git a/apps/web/app/api/user/group/[groupId]/rules/route.ts b/apps/web/app/api/user/group/[groupId]/rules/route.ts index 24dbd841d..5849e2005 100644 --- a/apps/web/app/api/user/group/[groupId]/rules/route.ts +++ b/apps/web/app/api/user/group/[groupId]/rules/route.ts @@ -7,14 +7,14 @@ import { SafeError } from "@/utils/error"; export type GroupRulesResponse = Awaited>; async function getGroupRules({ - userId, + emailAccountId, groupId, }: { - userId: string; + emailAccountId: string; groupId: string; }) { const groupWithRules = await prisma.group.findUnique({ - where: { id: groupId, userId }, + where: { id: groupId, emailAccountId }, select: { rule: { include: { @@ -31,16 +31,13 @@ async function getGroupRules({ export const GET = withError(async (_request: Request, { params }) => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const emailAccountId = session?.user.email; + if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Group id required" }); - const result = await getGroupRules({ - userId: session.user.id, - groupId, - }); + const result = await getGroupRules({ emailAccountId, groupId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/labels/route.ts b/apps/web/app/api/user/labels/route.ts index 62629427d..dd3020628 100644 --- a/apps/web/app/api/user/labels/route.ts +++ b/apps/web/app/api/user/labels/route.ts @@ -5,20 +5,18 @@ import { withError } from "@/utils/middleware"; export type UserLabelsResponse = Awaited>; -async function getLabels(options: { userId: string }) { +async function getLabels(options: { emailAccountId: string }) { return await prisma.label.findMany({ - where: { - userId: options.userId, - }, + where: { emailAccountId: options.emailAccountId }, }); } export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const emailAccountId = session?.user.email; + if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); - const labels = await getLabels({ userId: session.user.id }); + const labels = await getLabels({ emailAccountId }); return NextResponse.json(labels); }); diff --git a/apps/web/app/api/user/rules/[id]/example/route.ts b/apps/web/app/api/user/rules/[id]/example/route.ts index 05edf5f80..70676dba0 100644 --- a/apps/web/app/api/user/rules/[id]/example/route.ts +++ b/apps/web/app/api/user/rules/[id]/example/route.ts @@ -10,10 +10,11 @@ export type ExamplesResponse = Awaited>; async function getExamples(options: { ruleId: string }) { const session = await auth(); - if (!session?.user.email) throw new SafeError("Not logged in"); + const emailAccountId = session?.user.email; + if (!emailAccountId) throw new SafeError("Not logged in"); const rule = await prisma.rule.findUnique({ - where: { id: options.ruleId, userId: session.user.id }, + where: { id: options.ruleId, emailAccountId }, include: { group: { include: { items: true } } }, }); diff --git a/apps/web/app/api/user/stats/newsletters/summary/route.ts b/apps/web/app/api/user/stats/newsletters/summary/route.ts index d2258a645..55605cf87 100644 --- a/apps/web/app/api/user/stats/newsletters/summary/route.ts +++ b/apps/web/app/api/user/stats/newsletters/summary/route.ts @@ -7,9 +7,11 @@ export type NewsletterSummaryResponse = Awaited< ReturnType >; -async function getNewsletterSummary({ userId }: { userId: string }) { +async function getNewsletterSummary({ + emailAccountId, +}: { emailAccountId: string }) { const result = await prisma.newsletter.groupBy({ - where: { userId }, + where: { emailAccountId }, by: ["status"], _count: true, }); @@ -23,10 +25,10 @@ async function getNewsletterSummary({ userId }: { userId: string }) { export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const emailAccountId = session?.user.email; + if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); - const result = await getNewsletterSummary({ userId: session.user.id }); + const result = await getNewsletterSummary({ emailAccountId }); return NextResponse.json(result); }); From d9e4ab515754e2da9931fd74fc8be1fa571a2189 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 13:57:50 +0300 Subject: [PATCH 010/176] move remaning multi accounts over --- .../api/v1/group/[groupId]/emails/route.ts | 23 +++++++++--- .../v1/group/[groupId]/emails/validation.ts | 1 + apps/web/app/api/v1/helpers.ts | 36 +++++++++++++++++++ apps/web/app/api/v1/reply-tracker/route.ts | 20 +++++++++-- .../app/api/v1/reply-tracker/validation.ts | 1 + apps/web/utils/api-auth.ts | 8 ++++- 6 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/api/v1/helpers.ts diff --git a/apps/web/app/api/v1/group/[groupId]/emails/route.ts b/apps/web/app/api/v1/group/[groupId]/emails/route.ts index 44b9d0f18..0770ef1b6 100644 --- a/apps/web/app/api/v1/group/[groupId]/emails/route.ts +++ b/apps/web/app/api/v1/group/[groupId]/emails/route.ts @@ -1,4 +1,4 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { getGroupEmails } from "@/app/api/user/group/[groupId]/messages/controller"; import { groupEmailsQuerySchema, @@ -6,9 +6,11 @@ import { } from "@/app/api/v1/group/[groupId]/emails/validation"; import { withError } from "@/utils/middleware"; import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth"; +import { getEmailAccountId } from "@/app/api/v1/helpers"; export const GET = withError(async (request, { params }) => { - const { gmail, userId } = await validateApiKeyAndGetGmailClient(request); + const { gmail, userId, accountId } = + await validateApiKeyAndGetGmailClient(request); const { groupId } = await params; if (!groupId) @@ -26,11 +28,24 @@ export const GET = withError(async (request, { params }) => { ); } - const { pageToken, from, to } = queryResult.data; + const { pageToken, from, to, email } = queryResult.data; + + const emailAccountId = await getEmailAccountId({ + email, + accountId, + userId, + }); + + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 400 }, + ); + } const { messages, nextPageToken } = await getGroupEmails({ groupId, - userId, + emailAccountId, gmail, from: from ? new Date(from) : undefined, to: to ? new Date(to) : undefined, diff --git a/apps/web/app/api/v1/group/[groupId]/emails/validation.ts b/apps/web/app/api/v1/group/[groupId]/emails/validation.ts index 914e87ce8..b26fd7ff3 100644 --- a/apps/web/app/api/v1/group/[groupId]/emails/validation.ts +++ b/apps/web/app/api/v1/group/[groupId]/emails/validation.ts @@ -5,6 +5,7 @@ export const groupEmailsQuerySchema = z.object({ pageToken: z.string().optional(), from: z.coerce.number().optional(), to: z.coerce.number().optional(), + email: z.string().optional(), }); export const groupEmailsResponseSchema = z.object({ diff --git a/apps/web/app/api/v1/helpers.ts b/apps/web/app/api/v1/helpers.ts new file mode 100644 index 000000000..444faf4e6 --- /dev/null +++ b/apps/web/app/api/v1/helpers.ts @@ -0,0 +1,36 @@ +import prisma from "@/utils/prisma"; + +/** + * Gets the email account ID from the provided email or looks it up using the account ID + * @param email Optional email address + * @param accountId Account ID to look up the email if not provided + * @returns The email account ID or undefined if not found + */ +export async function getEmailAccountId({ + email, + accountId, + userId, +}: { + email?: string; + accountId?: string; + userId: string; +}): Promise { + if (email) { + // check user owns email account + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email, userId }, + select: { email: true }, + }); + + return emailAccount?.email; + } + + if (!accountId) return undefined; + + const emailAccount = await prisma.emailAccount.findFirst({ + where: { accountId }, + select: { email: true }, + }); + + return emailAccount?.email; +} diff --git a/apps/web/app/api/v1/reply-tracker/route.ts b/apps/web/app/api/v1/reply-tracker/route.ts index f8e9aedb5..527d8a0a0 100644 --- a/apps/web/app/api/v1/reply-tracker/route.ts +++ b/apps/web/app/api/v1/reply-tracker/route.ts @@ -1,4 +1,4 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { withError } from "@/utils/middleware"; import { createScopedLogger } from "@/utils/logger"; import { @@ -10,11 +10,12 @@ import { ThreadTrackerType } from "@prisma/client"; import { getPaginatedThreadTrackers } from "@/app/(app)/reply-zero/fetch-trackers"; import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; import { isDefined } from "@/utils/types"; +import { getEmailAccountId } from "@/app/api/v1/helpers"; const logger = createScopedLogger("api/v1/reply-tracker"); export const GET = withError(async (request) => { - const { accessToken, userId } = + const { accessToken, userId, accountId } = await validateApiKeyAndGetGmailClient(request); const { searchParams } = new URL(request.url); @@ -29,6 +30,19 @@ export const GET = withError(async (request) => { ); } + const emailAccountId = await getEmailAccountId({ + email: queryResult.data.email, + accountId, + userId, + }); + + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account not found" }, + { status: 400 }, + ); + } + try { function getType(type: "needs-reply" | "needs-follow-up") { if (type === "needs-reply") return ThreadTrackerType.NEEDS_REPLY; @@ -37,7 +51,7 @@ export const GET = withError(async (request) => { } const { trackers, count } = await getPaginatedThreadTrackers({ - userId, + email: emailAccountId, type: getType(queryResult.data.type), page: queryResult.data.page, timeRange: queryResult.data.timeRange, diff --git a/apps/web/app/api/v1/reply-tracker/validation.ts b/apps/web/app/api/v1/reply-tracker/validation.ts index db7ad78f7..357c7246b 100644 --- a/apps/web/app/api/v1/reply-tracker/validation.ts +++ b/apps/web/app/api/v1/reply-tracker/validation.ts @@ -4,6 +4,7 @@ export const replyTrackerQuerySchema = z.object({ type: z.enum(["needs-reply", "needs-follow-up"]), page: z.number().optional().default(1), timeRange: z.enum(["all", "3d", "1w", "2w", "1m"]).optional().default("all"), + email: z.string().optional(), }); export const replyTrackerResponseSchema = z.object({ diff --git a/apps/web/utils/api-auth.ts b/apps/web/utils/api-auth.ts index bcd442f84..35c32aad9 100644 --- a/apps/web/utils/api-auth.ts +++ b/apps/web/utils/api-auth.ts @@ -40,6 +40,7 @@ export async function getUserFromApiKey(secretKey: string) { id: true, accounts: { select: { + id: true, access_token: true, refresh_token: true, expires_at: true, @@ -84,5 +85,10 @@ export async function validateApiKeyAndGetGmailClient(request: NextRequest) { if (!gmail) throw new SafeError("Error refreshing Gmail access token", 401); - return { gmail, accessToken: account.access_token, userId: user.id }; + return { + gmail, + accessToken: account.access_token, + userId: user.id, + accountId: account.id, + }; } From 066425ac1cce73f94403b54eca8a2b6f3999f0c7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 15:07:26 +0300 Subject: [PATCH 011/176] account switcher ui --- apps/web/components/AccountSwitcher.tsx | 107 ++++++++++++++++++++++ apps/web/components/SideNav.tsx | 17 ++-- apps/web/components/SideNavWithTopNav.tsx | 4 +- 3 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 apps/web/components/AccountSwitcher.tsx diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx new file mode 100644 index 000000000..55ce90b53 --- /dev/null +++ b/apps/web/components/AccountSwitcher.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from "react"; +import { ChevronsUpDown, Plus } from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; +import { Avatar } from "@/components/ui/avatar"; + +const emailAccounts = [ + { + email: "elie@example.com", + avatar: "/avatars/01.png", + name: "Elie", + }, + { + email: "support@example.com", + avatar: "/avatars/02.png", + name: "Support", + }, +]; + +export function AccountSwitcher({ + accounts = emailAccounts, +}: { + accounts?: { + name: string; + avatar: string; + email: string; + }[]; +}) { + const { isMobile } = useSidebar(); + const [activeAccount, setActiveAccount] = React.useState(accounts[0]); + + if (!activeAccount) { + return null; + } + + return ( + + + + + +
+ +
+
+ + {activeAccount.name} + + {activeAccount.email} +
+ +
+
+ + + Accounts + + {accounts.map((account, index) => ( + setActiveAccount(account)} + className="gap-2 p-2" + > + + {account.name} + ⌘{index + 1} + + ))} + + +
+ +
+
+ Add account +
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index e8cdf701a..f8bdbe796 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -50,6 +50,7 @@ import { useSplitLabels } from "@/hooks/useLabels"; import { LoadingContent } from "@/components/LoadingContent"; import { useCleanerEnabled } from "@/hooks/useFeatureFlags"; import { ClientOnly } from "@/components/ClientOnly"; +import { AccountSwitcher } from "@/components/AccountSwitcher"; type NavItem = { name: string; @@ -227,7 +228,7 @@ const bottomMailLinks: NavItem[] = [ }, ]; -export function AppSidebar({ ...props }: React.ComponentProps) { +export function SideNav({ ...props }: React.ComponentProps) { const navigation = useNavigation(); const path = usePathname(); const showMailNav = path === "/mail" || path === "/compose"; @@ -251,15 +252,16 @@ export function AppSidebar({ ...props }: React.ComponentProps) { return ( - {state === "expanded" ? ( - + + {state === "expanded" ? ( -
- +
+
- - ) : null} + ) : null} + + @@ -290,7 +292,6 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - {/* */} ); diff --git a/apps/web/components/SideNavWithTopNav.tsx b/apps/web/components/SideNavWithTopNav.tsx index 649619356..107456861 100644 --- a/apps/web/components/SideNavWithTopNav.tsx +++ b/apps/web/components/SideNavWithTopNav.tsx @@ -6,7 +6,7 @@ import { SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar"; -import { AppSidebar } from "@/components/SideNav"; +import { SideNav } from "@/components/SideNav"; export function SideNavWithTopNav({ children, @@ -17,7 +17,7 @@ export function SideNavWithTopNav({ }) { return ( - + } /> From 716ed96a03682f87b1aa3b3ba2dcf99191ecff17 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 15:27:52 +0300 Subject: [PATCH 012/176] update cursor rules --- .cursor/rules/hooks.mdc | 20 ++++++++++++++++++++ .cursor/rules/index.mdc | 3 ++- .cursor/rules/server-actions.mdc | 2 ++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/hooks.mdc diff --git a/.cursor/rules/hooks.mdc b/.cursor/rules/hooks.mdc new file mode 100644 index 000000000..589debf44 --- /dev/null +++ b/.cursor/rules/hooks.mdc @@ -0,0 +1,20 @@ +--- +description: React hooks +globs: +alwaysApply: false +--- +# Custom Hook Guidelines + +This rule outlines the guidelines for creating custom React hooks within this project. + +## Custom Hooks + +- **Purpose:** Encapsulate reusable stateful logic, especially for data fetching or complex UI interactions. +- **Location:** Place custom hooks in the `apps/web/hooks/` directory. +- **Naming:** Use the `use` prefix (e.g., `useAccounts.ts`). +- **Data Fetching:** For fetching data from API endpoints, prefer using `useSWR`. Follow the guidelines outlined in [data-fetching.mdc](mdc:.cursor/rules/data-fetching.mdc). + - Create dedicated hooks for specific data types (e.g., `useAccounts`, `useLabels`). + - The hook should typically wrap `useSWR`, handle the API endpoint URL, and return the data, loading state, error state, and potentially the `mutate` function from SWR. +- **Simplicity:** Keep hooks focused on a single responsibility. + +By adhering to these guidelines, we ensure a consistent approach to reusable logic and data fetching throughout the application. diff --git a/.cursor/rules/index.mdc b/.cursor/rules/index.mdc index afad0b25c..f0d9d25d2 100644 --- a/.cursor/rules/index.mdc +++ b/.cursor/rules/index.mdc @@ -28,9 +28,10 @@ Guidelines for building user interfaces and handling frontend logic. | Rule File | Description | | :--------------------------------- | :---------------------------------------------------------- | | @page-structure.mdc | Page structure guidelines | -| @ui-components.mdc | UI component and styling guidelines (Shadcn, Radix, Tailwind) | +| @ui-components.mdc | UI component and styling guidelines (Shadcn, Tailwind) | | @form-handling.mdc | Form handling using React Hook Form and Zod | | @data-fetching.mdc | Fetching data from the API using SWR | +| @hooks.mdc | Guidelines for creating custom React hooks | ## Backend & API diff --git a/.cursor/rules/server-actions.mdc b/.cursor/rules/server-actions.mdc index c1cdd0359..4318e9369 100644 --- a/.cursor/rules/server-actions.mdc +++ b/.cursor/rules/server-actions.mdc @@ -60,6 +60,8 @@ export const deactivateApiKeyAction = withActionInstrumentation( ``` ## Implementation Guidelines +- **Mutations Only:** Server Actions are **strictly for mutations** (operations that change data, e.g., creating, updating, deleting). **Do NOT use Server Actions for data fetching (GET operations).** + - For data fetching, use dedicated [GET API Routes](mdc:.cursor/rules/get-api-route.mdc) combined with [SWR Hooks](mdc:.cursor/rules/custom-hooks.mdc). - Implement type-safe server actions with proper validation - Define input schemas using Zod for robust type checking and validation - Handle errors gracefully and return appropriate responses From 653f5337aa2c138e727445681877ccb32ed10a1e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 15:35:09 +0300 Subject: [PATCH 013/176] add account fetch hook --- apps/web/app/api/user/accounts/route.ts | 36 +++++++++++++++++++++++++ apps/web/hooks/useAccounts.ts | 8 ++++++ 2 files changed, 44 insertions(+) create mode 100644 apps/web/app/api/user/accounts/route.ts create mode 100644 apps/web/hooks/useAccounts.ts diff --git a/apps/web/app/api/user/accounts/route.ts b/apps/web/app/api/user/accounts/route.ts new file mode 100644 index 000000000..b621b3f4e --- /dev/null +++ b/apps/web/app/api/user/accounts/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { withError } from "@/utils/middleware"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("api/accounts"); + +export type GetAccountsResponse = Awaited>; + +async function getAccounts({ userId }: { userId: string }) { + const accounts = await prisma.emailAccount.findMany({ + where: { userId }, + select: { + email: true, + accountId: true, + user: { select: { name: true, image: true } }, + }, + orderBy: { + email: "asc", + }, + }); + + return { accounts }; +} + +export const GET = withError(async () => { + const session = await auth(); + const userId = session?.user?.id; + + if (!userId) + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + + const result = await getAccounts({ userId }); + return NextResponse.json(result); +}); diff --git a/apps/web/hooks/useAccounts.ts b/apps/web/hooks/useAccounts.ts new file mode 100644 index 000000000..bd80851a8 --- /dev/null +++ b/apps/web/hooks/useAccounts.ts @@ -0,0 +1,8 @@ +import useSWR from "swr"; +import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; + +export function useAccounts() { + return useSWR("/api/user/accounts", { + revalidateOnFocus: false, + }); +} From 609e433f58ad46c80f486010cbc7cb1b49702038 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:02:51 +0300 Subject: [PATCH 014/176] show accounts --- apps/web/app/api/user/accounts/route.ts | 3 - apps/web/components/AccountSwitcher.tsx | 90 +++++++++++++++---------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/apps/web/app/api/user/accounts/route.ts b/apps/web/app/api/user/accounts/route.ts index b621b3f4e..c90c8f2d2 100644 --- a/apps/web/app/api/user/accounts/route.ts +++ b/apps/web/app/api/user/accounts/route.ts @@ -2,9 +2,6 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { withError } from "@/utils/middleware"; -import { createScopedLogger } from "@/utils/logger"; - -const logger = createScopedLogger("api/accounts"); export type GetAccountsResponse = Awaited>; diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 55ce90b53..4dcd9dc48 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -1,8 +1,9 @@ "use client"; -import * as React from "react"; +import { useMemo } from "react"; +import Image from "next/image"; +import { useQueryState } from "nuqs"; import { ChevronsUpDown, Plus } from "lucide-react"; - import { DropdownMenu, DropdownMenuContent, @@ -18,36 +19,41 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; -import { Avatar } from "@/components/ui/avatar"; +import { useAccounts } from "@/hooks/useAccounts"; +import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; -const emailAccounts = [ - { - email: "elie@example.com", - avatar: "/avatars/01.png", - name: "Elie", - }, - { - email: "support@example.com", - avatar: "/avatars/02.png", - name: "Support", - }, -]; +export function AccountSwitcher() { + const { data: accountsData } = useAccounts(); + const [accountId, setAccountId] = useQueryState("accountId"); -export function AccountSwitcher({ - accounts = emailAccounts, + return ( + + ); +} + +export function AccountSwitcherInternal({ + accounts, + accountId, + setAccountId, }: { - accounts?: { - name: string; - avatar: string; - email: string; - }[]; + accounts: GetAccountsResponse["accounts"]; + accountId: string | null; + setAccountId: (accountId: string) => void; }) { const { isMobile } = useSidebar(); - const [activeAccount, setActiveAccount] = React.useState(accounts[0]); - if (!activeAccount) { - return null; - } + const activeAccount = useMemo( + () => + accounts.find((account) => account.accountId === accountId) || + accounts?.[0], + [accounts, accountId], + ); + + if (!activeAccount) return null; return ( @@ -58,14 +64,11 @@ export function AccountSwitcher({ size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > -
- -
+
- {activeAccount.name} + {activeAccount.user.name} - {activeAccount.email}
@@ -81,12 +84,12 @@ export function AccountSwitcher({ {accounts.map((account, index) => ( setActiveAccount(account)} + key={account.accountId} + onClick={() => setAccountId(account.accountId)} className="gap-2 p-2" > - - {account.name} + + {account.user.name} ⌘{index + 1} ))} @@ -105,3 +108,20 @@ export function AccountSwitcher({
); } + +function ProfileImage({ + image, + size = 24, +}: { image: string | null; size?: number }) { + if (!image) return null; + + return ( + + ); +} From f0c3f4cf62ffbebb8d0fec093284e38718a613fd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:27:24 +0300 Subject: [PATCH 015/176] fix mobile switcher --- apps/web/components/AccountSwitcher.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 4dcd9dc48..40ad39c41 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -64,7 +64,9 @@ export function AccountSwitcherInternal({ size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - +
+ +
{activeAccount.user.name} From d61b90fa41ccb47413b1d80a2ed06fbe34c8d197 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:29:31 +0300 Subject: [PATCH 016/176] symbol --- apps/web/components/AccountSwitcher.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 40ad39c41..2759e446b 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -21,6 +21,7 @@ import { } from "@/components/ui/sidebar"; import { useAccounts } from "@/hooks/useAccounts"; import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; +import { useModifierKey } from "@/hooks/useModifierKey"; export function AccountSwitcher() { const { data: accountsData } = useAccounts(); @@ -45,6 +46,7 @@ export function AccountSwitcherInternal({ setAccountId: (accountId: string) => void; }) { const { isMobile } = useSidebar(); + const { symbol: modifierSymbol } = useModifierKey(); const activeAccount = useMemo( () => @@ -92,7 +94,10 @@ export function AccountSwitcherInternal({ > {account.user.name} - ⌘{index + 1} + + {modifierSymbol} + {index + 1} + ))} From 82824c86df74d932333caf687447215b7c2ca015 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:33:38 +0300 Subject: [PATCH 017/176] keyboard shortcut switcher --- apps/web/components/AccountSwitcher.tsx | 47 ++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 2759e446b..90dc2b760 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useCallback } from "react"; import Image from "next/image"; import { useQueryState } from "nuqs"; import { ChevronsUpDown, Plus } from "lucide-react"; @@ -22,6 +22,7 @@ import { import { useAccounts } from "@/hooks/useAccounts"; import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; import { useModifierKey } from "@/hooks/useModifierKey"; +import { useHotkeys } from "react-hotkeys-hook"; export function AccountSwitcher() { const { data: accountsData } = useAccounts(); @@ -48,6 +49,8 @@ export function AccountSwitcherInternal({ const { isMobile } = useSidebar(); const { symbol: modifierSymbol } = useModifierKey(); + useAccountHotkeys(accounts, setAccountId); + const activeAccount = useMemo( () => accounts.find((account) => account.accountId === accountId) || @@ -132,3 +135,45 @@ function ProfileImage({ /> ); } + +function useAccountHotkeys( + accounts: GetAccountsResponse["accounts"], + setAccountId: (accountId: string) => void, +) { + const { isMac } = useModifierKey(); + const modifierKey = isMac ? "meta" : "ctrl"; + + const accountShortcuts = useMemo(() => { + return accounts.slice(0, 9).map((account, index) => ({ + hotkey: `${modifierKey}+${index + 1}`, + accountId: account.accountId, + })); + }, [accounts, modifierKey]); + + const hotkeyHandler = useCallback( + (event: KeyboardEvent) => { + const pressedDigit = Number.parseInt(event.key, 10); + if ( + !Number.isNaN(pressedDigit) && + pressedDigit >= 1 && + pressedDigit <= 9 + ) { + const accountIndex = pressedDigit - 1; + if (accounts[accountIndex]) { + setAccountId(accounts[accountIndex].accountId); + event.preventDefault(); // Prevent browser default behavior + } + } + }, + [accounts, setAccountId], + ); + + useHotkeys( + accountShortcuts.map((s) => s.hotkey).join(","), + hotkeyHandler, + { + preventDefault: true, // Keep for good measure, though handled in callback + }, + [accountShortcuts, hotkeyHandler], // Dependencies for useHotkeys + ); +} From 142178d61031f7afea50bf59935e03688208dc96 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:48:34 +0300 Subject: [PATCH 018/176] fix modifier crash --- apps/web/hooks/useModifierKey.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/hooks/useModifierKey.ts b/apps/web/hooks/useModifierKey.ts index 4829702e7..de8944fa7 100644 --- a/apps/web/hooks/useModifierKey.ts +++ b/apps/web/hooks/useModifierKey.ts @@ -1,5 +1,7 @@ export function useModifierKey() { - const isMac = /Mac|iPhone|iPod|iPad/.test(window.navigator.userAgent); + const isMac = + typeof window === "undefined" || + /Mac|iPhone|iPod|iPad/.test(window.navigator.userAgent); return { symbol: isMac ? "⌘" : "Ctrl", isMac }; } From fe9d943189d54e109f7f34b063787dcdd04038ba Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:48:45 +0300 Subject: [PATCH 019/176] add account link --- apps/web/components/AccountSwitcher.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 90dc2b760..09810e6b1 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -1,7 +1,9 @@ "use client"; import { useMemo, useCallback } from "react"; +import Link from "next/link"; import Image from "next/image"; +import { useHotkeys } from "react-hotkeys-hook"; import { useQueryState } from "nuqs"; import { ChevronsUpDown, Plus } from "lucide-react"; import { @@ -22,7 +24,6 @@ import { import { useAccounts } from "@/hooks/useAccounts"; import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; import { useModifierKey } from "@/hooks/useModifierKey"; -import { useHotkeys } from "react-hotkeys-hook"; export function AccountSwitcher() { const { data: accountsData } = useAccounts(); @@ -104,14 +105,16 @@ export function AccountSwitcherInternal({ ))} - -
- -
-
- Add account -
-
+ + +
+ +
+
+ Add account +
+
+ From 82edcf53ffcd1d07c4f9cf74c53aadcd6a7d700f Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 22:17:10 +0300 Subject: [PATCH 020/176] use account hook --- apps/web/components/AccountSwitcher.tsx | 24 +++++------------------- apps/web/hooks/useAccount.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 apps/web/hooks/useAccount.ts diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 09810e6b1..e8acb26fa 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -24,40 +24,26 @@ import { import { useAccounts } from "@/hooks/useAccounts"; import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; import { useModifierKey } from "@/hooks/useModifierKey"; +import { useAccount } from "@/hooks/useAccount"; export function AccountSwitcher() { const { data: accountsData } = useAccounts(); - const [accountId, setAccountId] = useQueryState("accountId"); - return ( - - ); + return ; } export function AccountSwitcherInternal({ accounts, - accountId, - setAccountId, }: { accounts: GetAccountsResponse["accounts"]; - accountId: string | null; - setAccountId: (accountId: string) => void; }) { const { isMobile } = useSidebar(); const { symbol: modifierSymbol } = useModifierKey(); - useAccountHotkeys(accounts, setAccountId); + const [, setAccountId] = useQueryState("accountId"); + const activeAccount = useAccount(); - const activeAccount = useMemo( - () => - accounts.find((account) => account.accountId === accountId) || - accounts?.[0], - [accounts, accountId], - ); + useAccountHotkeys(accounts, setAccountId); if (!activeAccount) return null; diff --git a/apps/web/hooks/useAccount.ts b/apps/web/hooks/useAccount.ts new file mode 100644 index 000000000..00f3e4918 --- /dev/null +++ b/apps/web/hooks/useAccount.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import { useQueryState } from "nuqs"; +import { useAccounts } from "@/hooks/useAccounts"; + +export function useAccount() { + const { data } = useAccounts(); + const [accountId] = useQueryState("accountId"); + + const account = useMemo(() => { + return ( + data?.accounts.find((account) => account.accountId === accountId) ?? + data?.accounts[0] + ); + }, [data, accountId]); + + return account; +} From 77d181fe42a6fe89c2bf34b92ef9c53aa7a887cd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 22:27:30 +0300 Subject: [PATCH 021/176] use account hook --- apps/web/components/AccountSwitcher.tsx | 3 ++- apps/web/hooks/useAccount.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index e8acb26fa..9ca4a9446 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -41,10 +41,11 @@ export function AccountSwitcherInternal({ const { symbol: modifierSymbol } = useModifierKey(); const [, setAccountId] = useQueryState("accountId"); - const activeAccount = useAccount(); + const { account: activeAccount, isLoading } = useAccount(); useAccountHotkeys(accounts, setAccountId); + if (isLoading) return null; if (!activeAccount) return null; return ( diff --git a/apps/web/hooks/useAccount.ts b/apps/web/hooks/useAccount.ts index 00f3e4918..229a6a513 100644 --- a/apps/web/hooks/useAccount.ts +++ b/apps/web/hooks/useAccount.ts @@ -3,7 +3,7 @@ import { useQueryState } from "nuqs"; import { useAccounts } from "@/hooks/useAccounts"; export function useAccount() { - const { data } = useAccounts(); + const { data, isLoading } = useAccounts(); const [accountId] = useQueryState("accountId"); const account = useMemo(() => { @@ -13,5 +13,5 @@ export function useAccount() { ); }, [data, accountId]); - return account; + return { account, isLoading }; } From 021713aef9b27cd8b70d83ee98b7fb100db15379 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 21 Apr 2025 23:06:15 +0300 Subject: [PATCH 022/176] clean up useaccount --- apps/web/components/AccountSwitcher.tsx | 4 +--- apps/web/hooks/useAccount.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 9ca4a9446..fccc2f294 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -4,7 +4,6 @@ import { useMemo, useCallback } from "react"; import Link from "next/link"; import Image from "next/image"; import { useHotkeys } from "react-hotkeys-hook"; -import { useQueryState } from "nuqs"; import { ChevronsUpDown, Plus } from "lucide-react"; import { DropdownMenu, @@ -40,8 +39,7 @@ export function AccountSwitcherInternal({ const { isMobile } = useSidebar(); const { symbol: modifierSymbol } = useModifierKey(); - const [, setAccountId] = useQueryState("accountId"); - const { account: activeAccount, isLoading } = useAccount(); + const { account: activeAccount, isLoading, setAccountId } = useAccount(); useAccountHotkeys(accounts, setAccountId); diff --git a/apps/web/hooks/useAccount.ts b/apps/web/hooks/useAccount.ts index 229a6a513..f87355184 100644 --- a/apps/web/hooks/useAccount.ts +++ b/apps/web/hooks/useAccount.ts @@ -4,7 +4,7 @@ import { useAccounts } from "@/hooks/useAccounts"; export function useAccount() { const { data, isLoading } = useAccounts(); - const [accountId] = useQueryState("accountId"); + const [accountId, setAccountId] = useQueryState("accountId"); const account = useMemo(() => { return ( @@ -13,5 +13,5 @@ export function useAccount() { ); }, [data, accountId]); - return { account, isLoading }; + return { account, isLoading, setAccountId }; } From 6d1ed9df8f7da059cb2fa3bb5540b33f6db621e7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 22 Apr 2025 00:07:09 +0300 Subject: [PATCH 023/176] add withauth middleware --- apps/web/app/api/user/rules/route.ts | 11 +-- apps/web/providers/SWRProvider.tsx | 21 ++++- apps/web/utils/middleware.ts | 92 ++++++++++++++++++---- apps/web/utils/redis/account-validation.ts | 59 ++++++++++++++ 4 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 apps/web/utils/redis/account-validation.ts diff --git a/apps/web/app/api/user/rules/route.ts b/apps/web/app/api/user/rules/route.ts index d02969300..864b66695 100644 --- a/apps/web/app/api/user/rules/route.ts +++ b/apps/web/app/api/user/rules/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import prisma from "@/utils/prisma"; export type RulesResponse = Awaited>; @@ -17,12 +16,8 @@ async function getRules({ email }: { email: string }) { }); } -export const GET = withError(async () => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); - +export const GET = withAuth(async (req) => { + const email = req.auth.userEmail; const result = await getRules({ email }); - return NextResponse.json(result); }); diff --git a/apps/web/providers/SWRProvider.tsx b/apps/web/providers/SWRProvider.tsx index 15741f520..c8e4ab05a 100644 --- a/apps/web/providers/SWRProvider.tsx +++ b/apps/web/providers/SWRProvider.tsx @@ -3,6 +3,7 @@ import { useCallback, useState, createContext, useMemo } from "react"; import { SWRConfig, mutate } from "swr"; import { captureException } from "@/utils/error"; +import { useAccount } from "@/hooks/useAccount"; // https://swr.vercel.app/docs/error-handling#status-code-and-error-object const fetcher = async (url: string, init?: RequestInit | undefined) => { @@ -56,6 +57,7 @@ const SWRContext = createContext(defaultContextValue); export const SWRProvider = (props: { children: React.ReactNode }) => { const [provider, setProvider] = useState(new Map()); + const { account } = useAccount(); const resetCache = useCallback(() => { // based on: https://swr.vercel.app/docs/mutation#mutate-multiple-items @@ -65,13 +67,30 @@ export const SWRProvider = (props: { children: React.ReactNode }) => { setProvider(new Map()); }, []); + const enhancedFetcher = useCallback( + async (url: string, init?: RequestInit) => { + const headers = new Headers(init?.headers); + + if (account?.accountId) { + headers.set("X-Account-ID", account.accountId); + } + + const newInit = { ...init, headers }; + + return fetcher(url, newInit); + }, + [account?.accountId], + ); + const value = useMemo(() => ({ resetCache }), [resetCache]); return ( - provider }}> + provider }}> {props.children} ); }; + +export { SWRContext }; diff --git a/apps/web/utils/middleware.ts b/apps/web/utils/middleware.ts index 3d17435fa..d3df734b9 100644 --- a/apps/web/utils/middleware.ts +++ b/apps/web/utils/middleware.ts @@ -5,18 +5,46 @@ import { env } from "@/env"; import { logErrorToPosthog } from "@/utils/error.server"; import { createScopedLogger } from "@/utils/logger"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { validateUserAccount } from "@/utils/redis/account-validation"; const logger = createScopedLogger("middleware"); -export type NextHandler = ( - req: NextRequest, +export type NextHandler = ( + req: T, context: { params: Promise> }, ) => Promise; -export function withError(handler: NextHandler): NextHandler { +// Extended request type with validated account info +export interface RequestWithAuth extends NextRequest { + auth: { + userId: string; + userEmail: string; + }; +} + +// Higher-order middleware factory that handles common error logic +function withMiddleware( + handler: NextHandler, + middleware?: (req: NextRequest) => Promise, +): NextHandler { return async (req, context) => { try { - return await handler(req, context); + // Apply middleware if provided + let enhancedReq = req; + if (middleware) { + const middlewareResult = await middleware(req); + + // If middleware returned a Response, return it directly + if (middlewareResult instanceof Response) { + return middlewareResult; + } + + // Otherwise, continue with the enhanced request + enhancedReq = middlewareResult; + } + + // Execute the handler with the (potentially) enhanced request + return await handler(enhancedReq as T, context); } catch (error) { if (error instanceof ZodError) { if (env.LOG_ZOD_ERRORS) { @@ -57,7 +85,6 @@ export function withError(handler: NextHandler): NextHandler { logger.error("Unhandled error", { error, url: req.url, - email: await getEmailFromRequest(), }); captureException(error, { extra: { url: req.url } }); @@ -69,6 +96,51 @@ export function withError(handler: NextHandler): NextHandler { }; } +async function authMiddleware( + req: NextRequest, +): Promise { + const session = await auth(); + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized", isKnownError: true }, + { status: 401 }, + ); + } + + const userId = session.user.id; + let userEmail = session.user.email; + + // Check for X-Account-ID header + const accountId = req.headers.get("X-Account-ID"); + + // If account ID is provided, validate and get the email account ID + if (accountId) { + userEmail = await validateUserAccount(userId, accountId); + } + + if (!userEmail) { + return NextResponse.json( + { error: "Invalid account ID", isKnownError: true }, + { status: 403 }, + ); + } + + // Create a new request with auth info + const authReq = req.clone() as RequestWithAuth; + authReq.auth = { userId, userEmail }; + + return authReq; +} + +// Public middlewares that build on the common infrastructure +export function withError(handler: NextHandler): NextHandler { + return withMiddleware(handler); +} + +export function withAuth(handler: NextHandler): NextHandler { + return withMiddleware(handler, authMiddleware); +} + function isErrorWithConfigAndHeaders( error: unknown, ): error is { config: { headers: unknown } } { @@ -79,13 +151,3 @@ function isErrorWithConfigAndHeaders( "headers" in (error as { config: any }).config ); } - -async function getEmailFromRequest() { - try { - const session = await auth(); - return session?.user.email; - } catch (error) { - logger.error("Error getting email from request", { error }); - return null; - } -} diff --git a/apps/web/utils/redis/account-validation.ts b/apps/web/utils/redis/account-validation.ts new file mode 100644 index 000000000..b584a1fa9 --- /dev/null +++ b/apps/web/utils/redis/account-validation.ts @@ -0,0 +1,59 @@ +import "server-only"; +import { redis } from "@/utils/redis"; +import prisma from "@/utils/prisma"; + +const EXPIRATION = 60 * 60; // 1 hour + +/** + * Get the Redis key for account validation + */ +function getValidationKey(userId: string, accountId: string): string { + return `account:${userId}:${accountId}`; +} + +/** + * Validate that an account belongs to a user, using Redis for caching + * @param userId The user ID + * @param accountId The account ID to validate + * @returns email address of the account if it belongs to the user, otherwise null + */ +export async function validateUserAccount( + userId: string, + accountId: string, +): Promise { + if (!userId || !accountId) return null; + + const key = getValidationKey(userId, accountId); + + // Check Redis cache first + const cachedResult = await redis.get(key); + + if (cachedResult !== null) { + return cachedResult; + } + + // Not in cache, check database + const emailAccount = await prisma.emailAccount.findUnique({ + where: { accountId, userId }, + select: { email: true }, + }); + + const isValid = !!emailAccount; + + // Cache the result + await redis.set(key, isValid ? "true" : "false", { ex: EXPIRATION }); + + return isValid ? emailAccount?.email : null; +} + +/** + * Invalidate the cached validation result for a user's account + * Useful when account ownership changes + */ +export async function invalidateAccountValidation( + userId: string, + accountId: string, +): Promise { + const key = getValidationKey(userId, accountId); + await redis.del(key); +} From 86a2c83240a0f1ce73acb190269846ef92c9ca47 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 22 Apr 2025 01:04:54 +0300 Subject: [PATCH 024/176] fix nuqs --- apps/web/hooks/useAccount.ts | 2 ++ apps/web/providers/AppProviders.tsx | 5 +---- apps/web/providers/GlobalProviders.tsx | 17 ++++++++++------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/web/hooks/useAccount.ts b/apps/web/hooks/useAccount.ts index f87355184..9e5a747a4 100644 --- a/apps/web/hooks/useAccount.ts +++ b/apps/web/hooks/useAccount.ts @@ -1,3 +1,5 @@ +"use client"; + import { useMemo } from "react"; import { useQueryState } from "nuqs"; import { useAccounts } from "@/hooks/useAccounts"; diff --git a/apps/web/providers/AppProviders.tsx b/apps/web/providers/AppProviders.tsx index 9161179f2..e80d07f34 100644 --- a/apps/web/providers/AppProviders.tsx +++ b/apps/web/providers/AppProviders.tsx @@ -2,7 +2,6 @@ import type React from "react"; import { Provider } from "jotai"; -import { NuqsAdapter } from "nuqs/adapters/next/app"; import { ComposeModalProvider } from "@/providers/ComposeModalProvider"; import { jotaiStore } from "@/store"; import { ThemeProvider } from "@/components/theme-provider"; @@ -11,9 +10,7 @@ export function AppProviders(props: { children: React.ReactNode }) { return ( - - {props.children} - + {props.children} ); diff --git a/apps/web/providers/GlobalProviders.tsx b/apps/web/providers/GlobalProviders.tsx index 12d04b2d2..63726ac35 100644 --- a/apps/web/providers/GlobalProviders.tsx +++ b/apps/web/providers/GlobalProviders.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; import { SessionProvider } from "@/providers/SessionProvider"; import { SWRProvider } from "@/providers/SWRProvider"; import { StatLoaderProvider } from "@/providers/StatLoaderProvider"; @@ -6,12 +7,14 @@ import { ComposeModalProvider } from "@/providers/ComposeModalProvider"; export function GlobalProviders(props: { children: React.ReactNode }) { return ( - - - - {props.children} - - - + + + + + {props.children} + + + + ); } From 3fc590b1d4be64dfea27e5256e04f09d38c4d6fe Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 22 Apr 2025 01:36:41 +0300 Subject: [PATCH 025/176] withauth --- .../app/api/ai/compose-autocomplete/route.ts | 9 ++-- apps/web/app/api/ai/models/route.ts | 9 ++-- apps/web/app/api/ai/summarise/route.ts | 12 ++---- apps/web/app/api/clean/history/route.ts | 11 ++--- apps/web/app/api/google/contacts/route.ts | 14 +++---- .../user/group/[groupId]/messages/route.ts | 15 +++---- apps/web/app/api/user/group/route.ts | 9 ++-- apps/web/app/api/user/labels/route.ts | 9 ++-- apps/web/app/api/user/me/route.ts | 9 ++-- apps/web/app/api/user/no-reply/route.ts | 16 ++++--- .../api/user/planned/get-executed-rules.ts | 29 +++++++------ .../web/app/api/user/planned/history/route.ts | 12 +++--- apps/web/app/api/user/planned/route.ts | 11 ++--- .../app/api/user/rules/[id]/example/route.ts | 29 ++++++------- apps/web/app/api/user/rules/[id]/route.ts | 15 ++----- apps/web/app/api/user/rules/prompt/route.ts | 9 ++-- .../api/user/settings/email-updates/route.ts | 27 +++++------- .../api/user/settings/multi-account/route.ts | 15 +++---- apps/web/app/api/user/settings/route.ts | 42 ++++++++----------- apps/web/app/api/user/stats/day/route.ts | 15 ++++--- .../app/api/user/stats/email-actions/route.ts | 28 ++++++------- .../app/api/user/stats/newsletters/route.ts | 23 +++++----- .../user/stats/newsletters/summary/route.ts | 17 +++----- apps/web/utils/account.ts | 15 +++++++ 24 files changed, 178 insertions(+), 222 deletions(-) create mode 100644 apps/web/utils/account.ts diff --git a/apps/web/app/api/ai/compose-autocomplete/route.ts b/apps/web/app/api/ai/compose-autocomplete/route.ts index 6331f6823..8698d96bf 100644 --- a/apps/web/app/api/ai/compose-autocomplete/route.ts +++ b/apps/web/app/api/ai/compose-autocomplete/route.ts @@ -1,14 +1,11 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { composeAutocompleteBody } from "@/app/api/ai/compose-autocomplete/validation"; import { chatCompletionStream } from "@/utils/llms"; import { getAiUser } from "@/utils/user/get"; -export const POST = withError(async (request: Request): Promise => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const POST = withAuth(async (request) => { + const email = request.auth.userEmail; const user = await getAiUser({ email }); diff --git a/apps/web/app/api/ai/models/route.ts b/apps/web/app/api/ai/models/route.ts index 436648b43..9f2bdf15e 100644 --- a/apps/web/app/api/ai/models/route.ts +++ b/apps/web/app/api/ai/models/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import OpenAI from "openai"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { Provider } from "@/utils/llms/config"; import { createScopedLogger } from "@/utils/logger"; @@ -18,10 +17,8 @@ async function getOpenAiModels({ apiKey }: { apiKey: string }) { return models.data.filter((m) => m.id.startsWith("gpt-")); } -export const GET = withError(async () => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (req) => { + const email = req.auth.userEmail; const emailAccount = await prisma.emailAccount.findUnique({ where: { email }, diff --git a/apps/web/app/api/ai/summarise/route.ts b/apps/web/app/api/ai/summarise/route.ts index 3e1872b47..e35315a0b 100644 --- a/apps/web/app/api/ai/summarise/route.ts +++ b/apps/web/app/api/ai/summarise/route.ts @@ -1,19 +1,13 @@ import { NextResponse } from "next/server"; import { summarise } from "@/app/api/ai/summarise/controller"; -import { withError } from "@/utils/middleware"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withAuth } from "@/utils/middleware"; import { summariseBody } from "@/app/api/ai/summarise/validation"; import { getSummary } from "@/utils/redis/summary"; import { emailToContent } from "@/utils/mail"; import { getAiUser } from "@/utils/user/get"; -// doesn't work with parsing email packages we use -// export const runtime = "edge"; - -export const POST = withError(async (request: Request) => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const POST = withAuth(async (request) => { + const email = request.auth.userEmail; const json = await request.json(); const body = summariseBody.parse(json); diff --git a/apps/web/app/api/clean/history/route.ts b/apps/web/app/api/clean/history/route.ts index 664741356..2397a93e5 100644 --- a/apps/web/app/api/clean/history/route.ts +++ b/apps/web/app/api/clean/history/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; export type CleanHistoryResponse = Awaited>; @@ -14,12 +13,8 @@ async function getCleanHistory({ email }: { email: string }) { return { result }; } -export const GET = withError(async () => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); - +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const result = await getCleanHistory({ email }); - return NextResponse.json(result); }); diff --git a/apps/web/app/api/google/contacts/route.ts b/apps/web/app/api/google/contacts/route.ts index eb07fd5b9..468446cfc 100644 --- a/apps/web/app/api/google/contacts/route.ts +++ b/apps/web/app/api/google/contacts/route.ts @@ -1,11 +1,12 @@ import type { people_v1 } from "@googleapis/people"; import { z } from "zod"; import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getContactsClient } from "@/utils/gmail/client"; import { searchContacts } from "@/utils/gmail/contact"; import { env } from "@/env"; +import prisma from "@/utils/prisma"; +import { getTokens } from "@/utils/account"; const contactsQuery = z.object({ query: z.string() }); export type ContactsQuery = z.infer; @@ -16,15 +17,14 @@ async function getContacts(client: people_v1.People, query: string) { return { result }; } -export const GET = withError(async (request) => { +export const GET = withAuth(async (request) => { if (!env.NEXT_PUBLIC_CONTACTS_ENABLED) return NextResponse.json({ error: "Contacts API not enabled" }); - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const tokens = await getTokens({ email: request.auth.userEmail }); + if (!tokens) return NextResponse.json({ error: "Account not found" }); - const client = getContactsClient(session); + const client = getContactsClient(tokens); const { searchParams } = new URL(request.url); const query = searchParams.get("query"); diff --git a/apps/web/app/api/user/group/[groupId]/messages/route.ts b/apps/web/app/api/user/group/[groupId]/messages/route.ts index d9e2cb3ee..3494e1828 100644 --- a/apps/web/app/api/user/group/[groupId]/messages/route.ts +++ b/apps/web/app/api/user/group/[groupId]/messages/route.ts @@ -1,18 +1,19 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getGroupEmails } from "@/app/api/user/group/[groupId]/messages/controller"; import { getGmailClient } from "@/utils/gmail/client"; +import { getTokens } from "@/utils/account"; -export const GET = withError(async (_request, { params }) => { - const session = await auth(); - const emailAccountId = session?.user.email; - if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request, { params }) => { + const emailAccountId = request.auth.userEmail; const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Missing group id" }); - const gmail = getGmailClient(session); + const tokens = await getTokens({ email: request.auth.userEmail }); + if (!tokens) return NextResponse.json({ error: "Account not found" }); + + const gmail = getGmailClient(tokens); const { messages } = await getGroupEmails({ groupId, diff --git a/apps/web/app/api/user/group/route.ts b/apps/web/app/api/user/group/route.ts index 69c5b21f3..5c732adf2 100644 --- a/apps/web/app/api/user/group/route.ts +++ b/apps/web/app/api/user/group/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; export type GroupsResponse = Awaited>; @@ -18,10 +17,8 @@ async function getGroups({ email }: { email: string }) { return { groups }; } -export const GET = withError(async () => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const result = await getGroups({ email }); diff --git a/apps/web/app/api/user/labels/route.ts b/apps/web/app/api/user/labels/route.ts index dd3020628..24a8f5945 100644 --- a/apps/web/app/api/user/labels/route.ts +++ b/apps/web/app/api/user/labels/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth, withError } from "@/utils/middleware"; export type UserLabelsResponse = Awaited>; @@ -11,10 +10,8 @@ async function getLabels(options: { emailAccountId: string }) { }); } -export const GET = withError(async () => { - const session = await auth(); - const emailAccountId = session?.user.email; - if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const emailAccountId = request.auth.userEmail; const labels = await getLabels({ emailAccountId }); diff --git a/apps/web/app/api/user/me/route.ts b/apps/web/app/api/user/me/route.ts index bb00bca4f..5792108d1 100644 --- a/apps/web/app/api/user/me/route.ts +++ b/apps/web/app/api/user/me/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { SafeError } from "@/utils/error"; export type UserResponse = Awaited>; @@ -45,10 +44,8 @@ async function getUser({ email }: { email: string }) { return emailAccount; } -export const GET = withError(async () => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const user = await getUser({ email }); diff --git a/apps/web/app/api/user/no-reply/route.ts b/apps/web/app/api/user/no-reply/route.ts index f9703079e..dcda7356c 100644 --- a/apps/web/app/api/user/no-reply/route.ts +++ b/apps/web/app/api/user/no-reply/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import type { gmail_v1 } from "@googleapis/gmail"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getGmailClient } from "@/utils/gmail/client"; import { type MessageWithPayload, isDefined } from "@/utils/types"; import { parseMessage } from "@/utils/mail"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getThread } from "@/utils/gmail/thread"; import { getMessages } from "@/utils/gmail/message"; +import { getTokens } from "@/utils/account"; export type NoReplyResponse = Awaited>; @@ -45,13 +45,11 @@ async function getNoReply(options: { email: string; gmail: gmail_v1.Gmail }) { return sentEmailsWithThreads; } -export const GET = withError(async () => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const gmail = getGmailClient(session); - const result = await getNoReply({ email: session.user.email, gmail }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const tokens = await getTokens({ email }); + const gmail = getGmailClient(tokens); + const result = await getNoReply({ email, gmail }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/planned/get-executed-rules.ts b/apps/web/app/api/user/planned/get-executed-rules.ts index d59f2253f..1c931cca8 100644 --- a/apps/web/app/api/user/planned/get-executed-rules.ts +++ b/apps/web/app/api/user/planned/get-executed-rules.ts @@ -1,33 +1,35 @@ -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getGmailClient } from "@/utils/gmail/client"; import { parseMessage } from "@/utils/mail"; import { isDefined } from "@/utils/types"; import { getMessage } from "@/utils/gmail/message"; import prisma from "@/utils/prisma"; -import { SafeError } from "@/utils/error"; import { ExecutedRuleStatus } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; +import { getTokens } from "@/utils/account"; const logger = createScopedLogger("api/user/planned/get-executed-rules"); const LIMIT = 50; -export async function getExecutedRules( - status: ExecutedRuleStatus, - page: number, - ruleId?: string, -) { - const session = await auth(); - if (!session?.user.email) throw new SafeError("Not authenticated"); - +export async function getExecutedRules({ + status, + page, + ruleId, + emailAccountId, +}: { + status: ExecutedRuleStatus; + page: number; + ruleId?: string; + emailAccountId: string; +}) { const where = { - userId: session.user.id, + emailAccountId, status: ruleId === "skipped" ? ExecutedRuleStatus.SKIPPED : status, rule: ruleId === "skipped" ? undefined : { isNot: null }, ruleId: ruleId === "all" || ruleId === "skipped" ? undefined : ruleId, }; - const [executedRules, total] = await Promise.all([ + const [executedRules, total, tokens] = await Promise.all([ prisma.executedRule.findMany({ where, take: LIMIT, @@ -51,9 +53,10 @@ export async function getExecutedRules( }, }), prisma.executedRule.count({ where }), + getTokens({ email: emailAccountId }), ]); - const gmail = getGmailClient(session); + const gmail = getGmailClient(tokens); const executedRulesWithMessages = await Promise.all( executedRules.map(async (p) => { diff --git a/apps/web/app/api/user/planned/history/route.ts b/apps/web/app/api/user/planned/history/route.ts index 3d50b33c1..1abf1d4c2 100644 --- a/apps/web/app/api/user/planned/history/route.ts +++ b/apps/web/app/api/user/planned/history/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { ExecutedRuleStatus } from "@prisma/client"; import { getExecutedRules } from "@/app/api/user/planned/get-executed-rules"; @@ -7,14 +7,16 @@ export const dynamic = "force-dynamic"; export type PlanHistoryResponse = Awaited>; -export const GET = withError(async (request) => { +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const url = new URL(request.url); const page = Number.parseInt(url.searchParams.get("page") || "1"); const ruleId = url.searchParams.get("ruleId") || "all"; - const messages = await getExecutedRules( - ExecutedRuleStatus.APPLIED, + const messages = await getExecutedRules({ + status: ExecutedRuleStatus.APPLIED, page, ruleId, - ); + emailAccountId: email, + }); return NextResponse.json(messages); }); diff --git a/apps/web/app/api/user/planned/route.ts b/apps/web/app/api/user/planned/route.ts index 67ae4e8e8..a3e3f310d 100644 --- a/apps/web/app/api/user/planned/route.ts +++ b/apps/web/app/api/user/planned/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { ExecutedRuleStatus } from "@prisma/client"; import { getExecutedRules } from "@/app/api/user/planned/get-executed-rules"; @@ -8,14 +8,15 @@ export const maxDuration = 30; // TODO not great if this is taking more than 15s export type PendingExecutedRules = Awaited>; -export const GET = withError(async (request) => { +export const GET = withAuth(async (request) => { const url = new URL(request.url); const page = Number.parseInt(url.searchParams.get("page") || "1"); const ruleId = url.searchParams.get("ruleId") || "all"; - const messages = await getExecutedRules( - ExecutedRuleStatus.PENDING, + const messages = await getExecutedRules({ + status: ExecutedRuleStatus.PENDING, page, ruleId, - ); + emailAccountId: request.auth.userEmail, + }); return NextResponse.json(messages); }); diff --git a/apps/web/app/api/user/rules/[id]/example/route.ts b/apps/web/app/api/user/rules/[id]/example/route.ts index 70676dba0..a33092592 100644 --- a/apps/web/app/api/user/rules/[id]/example/route.ts +++ b/apps/web/app/api/user/rules/[id]/example/route.ts @@ -1,41 +1,42 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getGmailClient } from "@/utils/gmail/client"; import { fetchExampleMessages } from "@/app/api/user/rules/[id]/example/controller"; import { SafeError } from "@/utils/error"; +import { getTokens } from "@/utils/account"; export type ExamplesResponse = Awaited>; -async function getExamples(options: { ruleId: string }) { - const session = await auth(); - const emailAccountId = session?.user.email; - if (!emailAccountId) throw new SafeError("Not logged in"); - +async function getExamples({ + ruleId, + email, +}: { + ruleId: string; + email: string; +}) { const rule = await prisma.rule.findUnique({ - where: { id: options.ruleId, emailAccountId }, + where: { id: ruleId, emailAccountId: email }, include: { group: { include: { items: true } } }, }); if (!rule) throw new SafeError("Rule not found"); - const gmail = getGmailClient(session); + const tokens = await getTokens({ email }); + const gmail = getGmailClient(tokens); const exampleMessages = await fetchExampleMessages(rule, gmail); return exampleMessages; } -export const GET = withError(async (_request, { params }) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request, { params }) => { + const email = request.auth.userEmail; const { id } = await params; if (!id) return NextResponse.json({ error: "Missing rule id" }); - const result = await getExamples({ ruleId: id }); + const result = await getExamples({ ruleId: id, email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/rules/[id]/route.ts b/apps/web/app/api/user/rules/[id]/route.ts index 5a5c2c56d..640c8df01 100644 --- a/apps/web/app/api/user/rules/[id]/route.ts +++ b/apps/web/app/api/user/rules/[id]/route.ts @@ -1,8 +1,6 @@ -import { z } from "zod"; import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; export type RuleResponse = Awaited>; @@ -13,18 +11,13 @@ async function getRule({ ruleId, email }: { ruleId: string; email: string }) { return { rule }; } -export const GET = withError(async (_request, { params }) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request, { params }) => { + const email = request.auth.userEmail; const { id } = await params; if (!id) return NextResponse.json({ error: "Missing rule id" }); - const result = await getRule({ - ruleId: id, - email: session.user.email, - }); + const result = await getRule({ ruleId: id, email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/rules/prompt/route.ts b/apps/web/app/api/user/rules/prompt/route.ts index 8a6a15043..ebe2420b9 100644 --- a/apps/web/app/api/user/rules/prompt/route.ts +++ b/apps/web/app/api/user/rules/prompt/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import prisma from "@/utils/prisma"; export type RulesPromptResponse = Awaited>; @@ -12,10 +11,8 @@ async function getRulesPrompt(options: { email: string }) { }); } -export const GET = withError(async () => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const result = await getRulesPrompt({ email }); diff --git a/apps/web/app/api/user/settings/email-updates/route.ts b/apps/web/app/api/user/settings/email-updates/route.ts index 1e294f188..1196251e5 100644 --- a/apps/web/app/api/user/settings/email-updates/route.ts +++ b/apps/web/app/api/user/settings/email-updates/route.ts @@ -1,39 +1,32 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { type SaveEmailUpdateSettingsBody, saveEmailUpdateSettingsBody, } from "@/app/api/user/settings/email-updates/validation"; -import { SafeError } from "@/utils/error"; export type SaveEmailUpdateSettingsResponse = Awaited< ReturnType >; -async function saveEmailUpdateSettings(options: SaveEmailUpdateSettingsBody) { - const session = await auth(); - if (!session?.user.email) throw new SafeError("Not logged in"); - +async function saveEmailUpdateSettings( + { email }: { email: string }, + { statsEmailFrequency, summaryEmailFrequency }: SaveEmailUpdateSettingsBody, +) { return await prisma.emailAccount.update({ - where: { email: session.user.email }, - data: { - statsEmailFrequency: options.statsEmailFrequency, - summaryEmailFrequency: options.summaryEmailFrequency, - }, + where: { email }, + data: { statsEmailFrequency, summaryEmailFrequency }, }); } -export const POST = withError(async (request: Request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const POST = withAuth(async (request) => { + const email = request.auth.userEmail; const json = await request.json(); const body = saveEmailUpdateSettingsBody.parse(json); - const result = await saveEmailUpdateSettings(body); + const result = await saveEmailUpdateSettings({ email }, body); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/settings/multi-account/route.ts b/apps/web/app/api/user/settings/multi-account/route.ts index d5746e21a..54b90e77f 100644 --- a/apps/web/app/api/user/settings/multi-account/route.ts +++ b/apps/web/app/api/user/settings/multi-account/route.ts @@ -1,15 +1,14 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; export type MultiAccountEmailsResponse = Awaited< ReturnType >; -async function getMultiAccountEmails(options: { email: string }) { +async function getMultiAccountEmails({ email }: { email: string }) { const user = await prisma.user.findUnique({ - where: { email: options.email }, + where: { email }, select: { premium: { select: { @@ -26,12 +25,10 @@ async function getMultiAccountEmails(options: { email: string }) { }; } -export const GET = withError(async () => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; - const result = await getMultiAccountEmails({ email: session.user.email }); + const result = await getMultiAccountEmails({ email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/settings/route.ts b/apps/web/app/api/user/settings/route.ts index 6c42e189e..bae1cb7cc 100644 --- a/apps/web/app/api/user/settings/route.ts +++ b/apps/web/app/api/user/settings/route.ts @@ -1,43 +1,39 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; import { type SaveSettingsBody, saveSettingsBody, } from "@/app/api/user/settings/validation"; import { Model, Provider } from "@/utils/llms/config"; import { SafeError } from "@/utils/error"; +import { withAuth } from "@/utils/middleware"; export type SaveSettingsResponse = Awaited>; -async function saveAISettings(options: SaveSettingsBody) { - const session = await auth(); - const email = session?.user.email; - if (!email) throw new SafeError("Not logged in"); - +async function saveAISettings( + { email }: { email: string }, + { aiProvider, aiModel, aiApiKey }: SaveSettingsBody, +) { function getModel() { - switch (options.aiProvider) { + switch (aiProvider) { case Provider.OPEN_AI: - if (!options.aiApiKey) - throw new SafeError("OpenAI API key is required"); + if (!aiApiKey) throw new SafeError("OpenAI API key is required"); - return options.aiModel; + return aiModel; case Provider.ANTHROPIC: - if (options.aiApiKey) { + if (aiApiKey) { // use anthropic if api key set return Model.CLAUDE_3_7_SONNET_ANTHROPIC; } // use bedrock if no api key set return Model.CLAUDE_3_7_SONNET_BEDROCK; case Provider.GOOGLE: - return options.aiModel || Model.GEMINI_2_0_FLASH; + return aiModel || Model.GEMINI_2_0_FLASH; case Provider.GROQ: - return options.aiModel || Model.GROQ_LLAMA_3_3_70B; + return aiModel || Model.GROQ_LLAMA_3_3_70B; case Provider.OPENROUTER: - if (!options.aiApiKey) - throw new SafeError("OpenRouter API key is required"); - return options.aiModel; + if (!aiApiKey) throw new SafeError("OpenRouter API key is required"); + return aiModel; case Provider.OLLAMA: return Model.OLLAMA; default: @@ -48,22 +44,20 @@ async function saveAISettings(options: SaveSettingsBody) { return await prisma.emailAccount.update({ where: { email }, data: { - aiProvider: options.aiProvider, + aiProvider, aiModel: getModel(), - aiApiKey: options.aiApiKey || null, + aiApiKey: aiApiKey || null, }, }); } -export const POST = withError(async (request: Request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const POST = withAuth(async (request) => { + const email = request.auth.userEmail; const json = await request.json(); const body = saveSettingsBody.parse(json); - const result = await saveAISettings(body); + const result = await saveAISettings({ email }, body); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/day/route.ts b/apps/web/app/api/user/stats/day/route.ts index 5043774d2..18acbadcb 100644 --- a/apps/web/app/api/user/stats/day/route.ts +++ b/apps/web/app/api/user/stats/day/route.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import type { gmail_v1 } from "@googleapis/gmail"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getGmailClient } from "@/utils/gmail/client"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { dateToSeconds } from "@/utils/date"; import { getMessages } from "@/utils/gmail/message"; +import { getTokens } from "@/utils/account"; const statsByDayQuery = z.object({ type: z.enum(["inbox", "sent", "archived"]), @@ -81,20 +81,19 @@ function getQuery(type: StatsByDayQuery["type"], date: Date) { } } -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json<{ error: string }>({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const { searchParams } = new URL(request.url); const type = searchParams.get("type"); const query = statsByDayQuery.parse({ type }); - const gmail = getGmailClient(session); + const tokens = await getTokens({ email }); + const gmail = getGmailClient(tokens); const result = await getPastSevenDayStats({ ...query, - email: session.user.email, + email, gmail, }); diff --git a/apps/web/app/api/user/stats/email-actions/route.ts b/apps/web/app/api/user/stats/email-actions/route.ts index a39e7d98f..d2110128c 100644 --- a/apps/web/app/api/user/stats/email-actions/route.ts +++ b/apps/web/app/api/user/stats/email-actions/route.ts @@ -1,31 +1,27 @@ -import { z } from "zod"; import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getEmailActionsByDay } from "@inboxzero/tinybird"; export type EmailActionStatsResponse = Awaited< ReturnType >; -async function getEmailActionStats(options: { email: string }) { - const result = ( - await getEmailActionsByDay({ ownerEmail: options.email }) - ).data.map((d) => ({ - date: d.date, - Archived: d.archive_count, - Deleted: d.delete_count, - })); +async function getEmailActionStats({ email }: { email: string }) { + const result = (await getEmailActionsByDay({ ownerEmail: email })).data.map( + (d) => ({ + date: d.date, + Archived: d.archive_count, + Deleted: d.delete_count, + }), + ); return { result }; } -export const GET = withError(async () => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; - const result = await getEmailActionStats({ email: session.user.email }); + const result = await getEmailActionStats({ email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index 66844c646..37de68f3a 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { filterNewsletters, findAutoArchiveFilter, @@ -67,8 +66,9 @@ function getTypeFilters(types: NewsletterStatsQuery["types"]) { } async function getNewslettersTinybird( - options: { ownerEmail: string; userId: string } & NewsletterStatsQuery, + options: { emailAccountId: string } & NewsletterStatsQuery, ) { + const emailAccountId = options.emailAccountId; const types = getTypeFilters(options.types); const [newsletterCounts, autoArchiveFilters, userNewsletters] = @@ -78,7 +78,7 @@ async function getNewslettersTinybird( ...types, }), getAutoArchiveFilters(), - findNewsletterStatus({ emailAccountId: options.ownerEmail }), + findNewsletterStatus({ emailAccountId }), ]); const newsletters = newsletterCounts.map((email: NewsletterCountResult) => { @@ -119,7 +119,7 @@ type NewsletterCountRawResult = { async function getNewsletterCounts( options: NewsletterStatsQuery & { - userId: string; + emailAccountId: string; read?: boolean; unread?: boolean; archived?: boolean; @@ -162,8 +162,8 @@ async function getNewsletterCounts( } // Always filter by userId - whereConditions.push(`"userId" = $${queryParams.length + 1}`); - queryParams.push(options.userId); + whereConditions.push(`"emailAccountId" = $${queryParams.length + 1}`); + queryParams.push(options.emailAccountId); // Create WHERE clause const whereClause = whereConditions.length @@ -230,10 +230,8 @@ function getOrderByClause(orderBy: string): string { } } -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const { searchParams } = new URL(request.url); const params = newsletterStatsQuery.parse({ @@ -249,8 +247,7 @@ export const GET = withError(async (request) => { const result = await getNewslettersTinybird({ ...params, - ownerEmail: session.user.email, - userId: session.user.id, + emailAccountId: email, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/newsletters/summary/route.ts b/apps/web/app/api/user/stats/newsletters/summary/route.ts index 55605cf87..fb39685d7 100644 --- a/apps/web/app/api/user/stats/newsletters/summary/route.ts +++ b/apps/web/app/api/user/stats/newsletters/summary/route.ts @@ -1,17 +1,14 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; export type NewsletterSummaryResponse = Awaited< ReturnType >; -async function getNewsletterSummary({ - emailAccountId, -}: { emailAccountId: string }) { +async function getNewsletterSummary({ email }: { email: string }) { const result = await prisma.newsletter.groupBy({ - where: { emailAccountId }, + where: { emailAccountId: email }, by: ["status"], _count: true, }); @@ -23,12 +20,10 @@ async function getNewsletterSummary({ return { result: resultObject }; } -export const GET = withError(async () => { - const session = await auth(); - const emailAccountId = session?.user.email; - if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; - const result = await getNewsletterSummary({ emailAccountId }); + const result = await getNewsletterSummary({ email }); return NextResponse.json(result); }); diff --git a/apps/web/utils/account.ts b/apps/web/utils/account.ts new file mode 100644 index 000000000..91f3afe70 --- /dev/null +++ b/apps/web/utils/account.ts @@ -0,0 +1,15 @@ +import prisma from "@/utils/prisma"; + +export async function getTokens({ email }: { email: string }) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, + select: { + account: { select: { access_token: true, refresh_token: true } }, + }, + }); + + return { + accessToken: emailAccount?.account.access_token ?? undefined, + refreshToken: emailAccount?.account.refresh_token ?? undefined, + }; +} From 96306d5f6f7be3c02be0743ffe855f9a77ad8598 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:15:39 +0300 Subject: [PATCH 026/176] add next safe action --- apps/web/package.json | 1 + apps/web/utils/actions/rule.ts | 2 +- apps/web/utils/actions/safe-action.ts | 34 ++++++ apps/web/utils/actions/user.ts | 145 ++++++++++---------------- pnpm-lock.yaml | 32 ++++++ 5 files changed, 123 insertions(+), 91 deletions(-) create mode 100644 apps/web/utils/actions/safe-action.ts diff --git a/apps/web/package.json b/apps/web/package.json index 930e5b836..d42ec063c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -107,6 +107,7 @@ "next": "15.3.0", "next-auth": "5.0.0-beta.25", "next-axiom": "1.9.1", + "next-safe-action": "7.10.5", "next-sanity": "9", "next-themes": "0.4.6", "nodemailer": "6.10.1", diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 486d1b8d9..88872da3a 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -19,7 +19,7 @@ import { } from "@/utils/actions/rule.validation"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma, { isDuplicateError, isNotFoundError } from "@/utils/prisma"; -import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; +import { getGmailClient } from "@/utils/gmail/client"; import { aiFindExampleMatches } from "@/utils/ai/example-matches/find-example-matches"; import { withActionInstrumentation } from "@/utils/actions/middleware"; import { flattenConditions } from "@/utils/condition"; diff --git a/apps/web/utils/actions/safe-action.ts b/apps/web/utils/actions/safe-action.ts new file mode 100644 index 000000000..7209cee99 --- /dev/null +++ b/apps/web/utils/actions/safe-action.ts @@ -0,0 +1,34 @@ +import { createSafeActionClient } from "next-safe-action"; +import { withServerActionInstrumentation } from "@sentry/nextjs"; +import { z } from "zod"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("safe-action"); + +export const actionClient = createSafeActionClient({ + defineMetadataSchema() { + return z.object({ name: z.string() }); + }, +}) + .use(async ({ next, metadata }) => { + logger.info("Calling action", { name: metadata?.name }); + return next(); + }) + .use(async ({ next, metadata }) => { + const session = await auth(); + + if (!session?.user) throw new Error("Unauthorized"); + const userEmail = session.user.email; + if (!userEmail) throw new Error("Unauthorized"); + + return withServerActionInstrumentation(metadata?.name, async () => { + return next({ + ctx: { + userId: session.user.id, + userEmail, + session, + }, + }); + }); + }); diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index 56dc20365..7af205ea3 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -2,64 +2,49 @@ import { z } from "zod"; import { revalidatePath } from "next/cache"; -import { auth, signOut } from "@/app/api/auth/[...nextauth]/auth"; +import { signOut } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withActionInstrumentation } from "@/utils/actions/middleware"; import { deleteUser } from "@/utils/user/delete"; import { extractGmailSignature } from "@/utils/gmail/signature"; import { getGmailClient } from "@/utils/gmail/client"; import { getMessage, getMessages } from "@/utils/gmail/message"; import { parseMessage } from "@/utils/mail"; import { GmailLabel } from "@/utils/gmail/label"; +import { actionClient } from "@/utils/actions/safe-action"; const saveAboutBody = z.object({ about: z.string().max(2_000) }); export type SaveAboutBody = z.infer; -export const saveAboutAction = withActionInstrumentation( - "saveAbout", - async (unsafeBody: SaveAboutBody) => { - const session = await auth(); - const email = session?.user.email; - if (!email) return { error: "Not logged in" }; - - const { success, data, error } = saveAboutBody.safeParse(unsafeBody); - if (!success) return { error: error.message }; - +export const saveAboutAction = actionClient + .metadata({ name: "saveAbout" }) + .schema(saveAboutBody) + .action(async ({ parsedInput: { about }, ctx: { userEmail } }) => { await prisma.emailAccount.update({ - where: { email }, - data: { about: data.about }, + where: { email: userEmail }, + data: { about }, }); revalidatePath("/settings"); - }, -); + }); const saveSignatureBody = z.object({ signature: z.string().max(2_000) }); export type SaveSignatureBody = z.infer; -export const saveSignatureAction = withActionInstrumentation( - "saveSignature", - async (unsafeBody: SaveSignatureBody) => { - const session = await auth(); - const email = session?.user.email; - if (!email) return { error: "Not logged in" }; - - const { success, data, error } = saveSignatureBody.safeParse(unsafeBody); - if (!success) return { error: error.message }; - +export const saveSignatureAction = actionClient + .metadata({ name: "saveSignature" }) + .schema(saveSignatureBody) + .action(async ({ parsedInput: { signature }, ctx: { userEmail } }) => { await prisma.emailAccount.update({ - where: { email }, - data: { signature: data.signature }, + where: { email: userEmail }, + data: { signature }, }); - }, -); -export const loadSignatureFromGmailAction = withActionInstrumentation( - "loadSignatureFromGmail", - async () => { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; + revalidatePath("/settings"); + }); +export const loadSignatureFromGmailAction = actionClient + .metadata({ name: "loadSignatureFromGmail" }) + .action(async ({ ctx: { session } }) => { // 1. find last 5 sent emails const gmail = getGmailClient(session); const messages = await getMessages(gmail, { @@ -82,81 +67,61 @@ export const loadSignatureFromGmailAction = withActionInstrumentation( } return { signature: "" }; - }, -); - -export const resetAnalyticsAction = withActionInstrumentation( - "resetAnalytics", - async () => { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; + }); +export const resetAnalyticsAction = actionClient + .metadata({ name: "resetAnalytics" }) + .action(async ({ ctx: { userEmail } }) => { await prisma.emailMessage.deleteMany({ - where: { emailAccountId: session.user.email }, + where: { emailAccount: { email: userEmail } }, }); - }, -); - -export const deleteAccountAction = withActionInstrumentation( - "deleteAccount", - async () => { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; + }); +export const deleteAccountAction = actionClient + .metadata({ name: "deleteAccount" }) + .action(async ({ ctx: { userId, userEmail } }) => { try { await signOut(); } catch (error) {} - await deleteUser({ userId: session.user.id, email: session.user.email }); - }, -); - -export const completedOnboardingAction = withActionInstrumentation( - "completedOnboarding", - async () => { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + await deleteUser({ userId, email: userEmail }); + }); +export const completedOnboardingAction = actionClient + .metadata({ name: "completedOnboarding" }) + .action(async ({ ctx: { userId } }) => { await prisma.user.update({ - where: { id: session.user.id, completedOnboardingAt: null }, + where: { id: userId, completedOnboardingAt: null }, data: { completedOnboardingAt: new Date() }, }); - }, -); - -export const completedAppOnboardingAction = withActionInstrumentation( - "completedAppOnboarding", - async () => { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + }); +export const completedAppOnboardingAction = actionClient + .metadata({ name: "completedAppOnboarding" }) + .action(async ({ ctx: { userId } }) => { await prisma.user.update({ - where: { id: session.user.id, completedAppOnboardingAt: null }, + where: { id: userId, completedAppOnboardingAt: null }, data: { completedAppOnboardingAt: new Date() }, }); - }, -); + }); const saveOnboardingAnswersBody = z.object({ surveyId: z.string().optional(), questions: z.any(), answers: z.any(), }); -type SaveOnboardingAnswersBody = z.infer; - -export const saveOnboardingAnswersAction = withActionInstrumentation( - "saveOnboardingAnswers", - async (unsafeBody: SaveOnboardingAnswersBody) => { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - const { success, data, error } = - saveOnboardingAnswersBody.safeParse(unsafeBody); - if (!success) return { error: error.message }; - - await prisma.user.update({ - where: { id: session.user.id }, - data: { onboardingAnswers: data }, - }); - }, -); +export const saveOnboardingAnswersAction = actionClient + .metadata({ name: "saveOnboardingAnswers" }) + .schema(saveOnboardingAnswersBody) + .action( + async ({ + parsedInput: { surveyId, questions, answers }, + ctx: { userId }, + }) => { + await prisma.user.update({ + where: { id: userId }, + data: { onboardingAnswers: { surveyId, questions, answers } }, + }); + }, + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd8bc7a5a..a0e7c58d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,6 +386,9 @@ importers: next-axiom: specifier: 1.9.1 version: 1.9.1(next@15.3.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + next-safe-action: + specifier: 7.10.5 + version: 7.10.5(next@15.3.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.24.2) next-sanity: specifier: '9' version: 9.4.7(@sanity/client@6.29.0)(@sanity/icons@3.7.0(react@19.1.0))(@sanity/types@3.84.0(@types/react@19.0.10))(@sanity/ui@2.15.13(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.0(react@19.1.0))(react-is@18.3.1)(react@19.1.0)(styled-components@6.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0)))(next@15.3.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sanity@3.84.0(@emotion/is-prop-valid@1.2.2)(@types/node@22.14.1)(@types/react@19.0.10)(jiti@2.4.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(styled-components@6.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))(styled-components@6.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(svelte@4.2.12) @@ -9338,6 +9341,27 @@ packages: next: '>=14.0' react: '>=18.0.0' + next-safe-action@7.10.5: + resolution: {integrity: sha512-UQdu490byYN7034FYZtnPVFP3DjyvYBpmXSQ/dpdJFznSsGCDCmTRk+Hs51emt5jljEKtMcL1XCfBbFuGklmrA==} + engines: {node: '>=18.17'} + peerDependencies: + '@sinclair/typebox': '>= 0.33.3' + next: '>= 14.0.0' + react: '>= 18.2.0' + react-dom: '>= 18.2.0' + valibot: '>= 0.36.0' + yup: '>= 1.0.0' + zod: '>= 3.0.0' + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + valibot: + optional: true + yup: + optional: true + zod: + optional: true + next-sanity@9.4.7: resolution: {integrity: sha512-4p/crgc9bLD9kNJ/2OdC9ow3nbCI3okAwx6otN3aNAUd7Z7AGOINJQ9tUfrbLNEfHNNgSTQZnckjrh2B+kdawA==} engines: {node: '>=18.17'} @@ -22326,6 +22350,14 @@ snapshots: use-deep-compare: 1.3.0(react@19.1.0) whatwg-fetch: 3.6.20 + next-safe-action@7.10.5(next@15.3.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@3.24.2): + dependencies: + next: 15.3.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + zod: 3.24.2 + next-sanity@9.4.7(@sanity/client@6.29.0)(@sanity/icons@3.7.0(react@19.1.0))(@sanity/types@3.84.0(@types/react@19.0.10))(@sanity/ui@2.15.13(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.0(react@19.1.0))(react-is@18.3.1)(react@19.1.0)(styled-components@6.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0)))(next@15.3.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sanity@3.84.0(@emotion/is-prop-valid@1.2.2)(@types/node@22.14.1)(@types/react@19.0.10)(jiti@2.4.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(styled-components@6.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))(styled-components@6.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(svelte@4.2.12): dependencies: '@portabletext/react': 3.2.1(react@19.1.0) From f34b96d2e4986f228c3738b301c28e3435cea6b0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:47:22 +0300 Subject: [PATCH 027/176] move user actions to next safe action --- .../app/(app)/settings/AboutSectionForm.tsx | 32 ++++-- apps/web/app/(app)/settings/DeleteSection.tsx | 14 ++- .../(app)/settings/SignatureSectionForm.tsx | 41 ++++--- apps/web/utils/actions/safe-action.ts | 20 +++- apps/web/utils/actions/user.ts | 16 +-- apps/web/utils/user/delete.ts | 102 ++++++++++-------- 6 files changed, 145 insertions(+), 80 deletions(-) diff --git a/apps/web/app/(app)/settings/AboutSectionForm.tsx b/apps/web/app/(app)/settings/AboutSectionForm.tsx index 903467d58..56bd40c8b 100644 --- a/apps/web/app/(app)/settings/AboutSectionForm.tsx +++ b/apps/web/app/(app)/settings/AboutSectionForm.tsx @@ -1,6 +1,7 @@ "use client"; import { useForm } from "react-hook-form"; +import { useAction } from "next-safe-action/hooks"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; import { saveAboutAction, type SaveAboutBody } from "@/utils/actions/user"; @@ -10,24 +11,39 @@ import { FormSectionRight, SubmitButtonWrapper, } from "@/components/Form"; -import { handleActionResult } from "@/utils/server-action"; +import { useAccount } from "@/hooks/useAccount"; +import { toastError, toastSuccess } from "@/components/Toast"; export const AboutSectionForm = ({ about }: { about: string | null }) => { const { register, - formState: { errors, isSubmitting }, + formState: { errors }, handleSubmit, } = useForm({ defaultValues: { about: about ?? "" }, }); - const onSubmit = async (data: SaveAboutBody) => { - const result = await saveAboutAction(data); - handleActionResult(result, "Updated profile!"); - }; + const { account } = useAccount(); + const { execute, isExecuting } = useAction( + saveAboutAction.bind(null, account?.email || ""), + { + onSuccess: () => { + toastSuccess({ + description: "Your profile has been updated!", + }); + }, + onError: (error) => { + toastError({ + description: + error.error.serverError ?? + "An unknown error occurred while updating your profile", + }); + }, + }, + ); return ( -
+ - diff --git a/apps/web/app/(app)/settings/DeleteSection.tsx b/apps/web/app/(app)/settings/DeleteSection.tsx index 3f5cff109..fc76a7c5a 100644 --- a/apps/web/app/(app)/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/settings/DeleteSection.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAction } from "next-safe-action/hooks"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { FormSection, FormSectionLeft } from "@/components/Form"; @@ -10,10 +11,19 @@ import { import { logOut } from "@/utils/user"; import { isActionError } from "@/utils/error"; import { useStatLoader } from "@/providers/StatLoaderProvider"; +import { useAccount } from "@/hooks/useAccount"; export function DeleteSection() { const { onCancelLoadBatch } = useStatLoader(); + const { account } = useAccount(); + const { executeAsync: executeResetAnalytics } = useAction( + resetAnalyticsAction.bind(null, account?.email || ""), + ); + const { executeAsync: executeDeleteAccount } = useAction( + deleteAccountAction.bind(null, ""), + ); + return ( { - toast.promise(() => resetAnalyticsAction(), { + toast.promise(() => executeResetAnalytics(), { loading: "Resetting analytics...", success: () => { return "Analytics reset! Visit the Unsubscriber or Analytics page and click the 'Load More' button to reload your data."; @@ -51,7 +61,7 @@ export function DeleteSection() { toast.promise( async () => { - const result = await deleteAccountAction(); + const result = await executeDeleteAccount(); await logOut("/"); if (isActionError(result)) throw new Error(result.error); }, diff --git a/apps/web/app/(app)/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/settings/SignatureSectionForm.tsx index f0b34a1f7..c22da37d2 100644 --- a/apps/web/app/(app)/settings/SignatureSectionForm.tsx +++ b/apps/web/app/(app)/settings/SignatureSectionForm.tsx @@ -1,7 +1,8 @@ "use client"; import { useCallback, useRef } from "react"; -import { type SubmitHandler, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; +import { useAction } from "next-safe-action/hooks"; import { Button } from "@/components/Button"; import { saveSignatureAction, @@ -18,6 +19,7 @@ import { Tiptap, type TiptapHandle } from "@/components/editor/Tiptap"; import { isActionError } from "@/utils/error"; import { toastError, toastInfo, toastSuccess } from "@/components/Toast"; import { ClientOnly } from "@/components/ClientOnly"; +import { useAccount } from "@/hooks/useAccount"; export const SignatureSectionForm = ({ signature, @@ -26,23 +28,28 @@ export const SignatureSectionForm = ({ }) => { const defaultSignature = signature ?? ""; - const { - handleSubmit, - setValue, - formState: { isSubmitting }, - } = useForm({ + const { handleSubmit, setValue } = useForm({ defaultValues: { signature: defaultSignature }, }); const editorRef = useRef(null); - const onSubmit: SubmitHandler = useCallback( - async (data) => { - const res = await saveSignatureAction(data); - if (isActionError(res)) toastError({ description: res.error }); - else toastSuccess({ description: "Signature saved" }); + const { account } = useAccount(); + const { execute, isExecuting } = useAction( + saveSignatureAction.bind(null, account?.email || ""), + { + onSuccess: () => { + toastSuccess({ description: "Signature saved" }); + }, + onError: (error) => { + toastError({ + description: error.error.serverError ?? "An unknown error occurred", + }); + }, }, - [], + ); + const { executeAsync: executeLoadSignatureFromGmail } = useAction( + loadSignatureFromGmailAction.bind(null, account?.email || ""), ); const handleEditorChange = useCallback( @@ -53,7 +60,7 @@ export const SignatureSectionForm = ({ ); return ( - +
- ); From 173d94f73dee6d0764987938e91e534ab016c2a7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:07:42 +0300 Subject: [PATCH 044/176] assess --- apps/web/app/(app)/assess.tsx | 41 ++++++++++++++++++++++---------- apps/web/utils/actions/assess.ts | 2 +- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/web/app/(app)/assess.tsx b/apps/web/app/(app)/assess.tsx index 8ee73ecac..b275885e6 100644 --- a/apps/web/app/(app)/assess.tsx +++ b/apps/web/app/(app)/assess.tsx @@ -1,25 +1,42 @@ "use client"; +import { useAction } from "next-safe-action/hooks"; import { useEffect } from "react"; import { whitelistInboxZeroAction } from "@/utils/actions/whitelist"; import { analyzeWritingStyleAction, - assessUserAction, + assessAction, } from "@/utils/actions/assess"; - -async function assessUser() { - const result = await assessUserAction(); - // no need to run this over and over after the first time - if (!result.skipped) { - await whitelistInboxZeroAction(); - } -} +import { useAccount } from "@/providers/AccountProvider"; export function AssessUser() { + const { account } = useAccount(); + const { executeAsync: executeAssessAsync } = useAction( + assessAction.bind(null, account?.email || ""), + ); + const { execute: executeWhitelistInboxZero } = useAction( + whitelistInboxZeroAction.bind(null, account?.email || ""), + ); + const { execute: executeAnalyzeWritingStyle } = useAction( + analyzeWritingStyleAction.bind(null, account?.email || ""), + ); + useEffect(() => { - assessUser(); - analyzeWritingStyleAction(); - }, []); + async function assess() { + const result = await executeAssessAsync(); + // no need to run this over and over after the first time + if (!result?.data?.skipped) { + executeWhitelistInboxZero(); + } + } + + assess(); + executeAnalyzeWritingStyle(); + }, [ + executeAssessAsync, + executeWhitelistInboxZero, + executeAnalyzeWritingStyle, + ]); return null; } diff --git a/apps/web/utils/actions/assess.ts b/apps/web/utils/actions/assess.ts index 5fe099def..b80055ea8 100644 --- a/apps/web/utils/actions/assess.ts +++ b/apps/web/utils/actions/assess.ts @@ -10,7 +10,7 @@ import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; // to help with onboarding and provide the best flow to new users -export const assessUserAction = actionClient +export const assessAction = actionClient .metadata({ name: "assessUser" }) .action(async ({ ctx: { email } }) => { const gmail = await getGmailClientForEmail({ email }); From 7066a6c25f248962094d7815a3db72e6ae8eaa87 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:09:05 +0300 Subject: [PATCH 045/176] admin --- apps/web/app/(app)/admin/AdminUserControls.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/admin/AdminUserControls.tsx b/apps/web/app/(app)/admin/AdminUserControls.tsx index ee511f230..3ca24ee16 100644 --- a/apps/web/app/(app)/admin/AdminUserControls.tsx +++ b/apps/web/app/(app)/admin/AdminUserControls.tsx @@ -63,7 +63,7 @@ export const AdminUserControls = () => { handleActionResult( result, `Checked permissions for ${email}. ${ - result?.hasAllPermissions + result?.data?.hasAllPermissions ? "Has all permissions" : "Missing permissions" }`, @@ -79,7 +79,7 @@ export const AdminUserControls = () => { onClick={async () => { setIsDeleting(true); const email = getValues("email"); - const result = await adminDeleteAccountAction(email); + const result = await adminDeleteAccountAction({ email }); handleActionResult(result, "Deleted user"); setIsDeleting(false); }} From 77807d345357482326f6d780107064073258c7c4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:24:15 +0300 Subject: [PATCH 046/176] report mistake --- .../app/(app)/automation/ReportMistake.tsx | 144 +++++++++++------- 1 file changed, 88 insertions(+), 56 deletions(-) diff --git a/apps/web/app/(app)/automation/ReportMistake.tsx b/apps/web/app/(app)/automation/ReportMistake.tsx index 6b724ef23..87d86e5de 100644 --- a/apps/web/app/(app)/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/automation/ReportMistake.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAction } from "next-safe-action/hooks"; import { useCallback, useMemo, useState } from "react"; import Link from "next/link"; import { @@ -61,6 +62,7 @@ import { useCategories } from "@/hooks/useCategories"; import { CategorySelect } from "@/components/CategorySelect"; import { useModal } from "@/hooks/useModal"; import { ConditionType } from "@/utils/config"; +import { useAccount } from "@/providers/AccountProvider"; type ReportMistakeView = "select-expected-rule" | "ai-fix" | "manual-fix"; @@ -126,7 +128,6 @@ function Content({ isTest: boolean; onClose: () => void; }) { - const [loadingAiFix, setLoadingAiFix] = useState(false); const [fixedInstructions, setFixedInstructions] = useState<{ ruleId: string; fixedInstructions: string; @@ -159,6 +160,11 @@ function Content({ }); }, []); + const { account } = useAccount(); + const { executeAsync, isExecuting } = useAction( + reportAiMistakeAction.bind(null, account?.email || ""), + ); + const onSelectExpectedRule = useCallback( async (expectedRuleId: string | null) => { setExpectedRuleId(expectedRuleId); @@ -172,11 +178,10 @@ function Content({ if (isEitherAIRule) { onSetView("ai-fix"); - setLoadingAiFix(true); - const response = await reportAiMistakeAction({ + const response = await executeAsync({ actualRuleId: result?.rule?.id, expectedRuleId: expectedRule?.id, - email: { + message: { from: message.headers.from, subject: message.headers.subject, snippet: message.snippet, @@ -185,17 +190,16 @@ function Content({ }, }); - setLoadingAiFix(false); if (isActionError(response)) { toastError({ title: "Error reporting mistake", description: response.error, }); } else { - if (response.ruleId) { + if (response?.data?.ruleId) { setFixedInstructions({ - ruleId: response.ruleId, - fixedInstructions: response.fixedInstructions, + ruleId: response.data.ruleId, + fixedInstructions: response.data.fixedInstructions, }); } else { toastError({ @@ -210,7 +214,7 @@ function Content({ onSetView("manual-fix"); } }, - [message, result?.rule?.id, onSetView, actualRule, rules], + [message, result?.rule?.id, onSetView, actualRule, rules, executeAsync], ); if (view === "select-expected-rule") { @@ -295,7 +299,7 @@ function Content({ if (view === "ai-fix") { return ( void; onClose: () => void; }) { + const { account } = useAccount(); + const { executeAsync, isExecuting } = useAction( + addGroupItemAction.bind(null, account?.email || ""), + ); + return (
@@ -471,10 +480,11 @@ function GroupMismatchAdd({ @@ -777,10 +794,15 @@ function AIFixForm({ fixedInstructions: string; }>(); + const { account } = useAccount(); + const { executeAsync, isExecuting } = useAction( + reportAiMistakeAction.bind(null, account?.email || ""), + ); + const { register, handleSubmit, - formState: { errors, isSubmitting }, + formState: { errors }, } = useForm({ resolver: zodResolver(reportAiMistakeBody), defaultValues: { @@ -803,7 +825,7 @@ function AIFixForm({ const reportMistake: SubmitHandler = useCallback( async (data) => { - const response = await reportAiMistakeAction(data); + const response = await executeAsync(data); if (isActionError(response)) { toastError({ @@ -812,13 +834,13 @@ function AIFixForm({ }); } else { toastSuccess({ - description: `This is the updated rule: ${response.fixedInstructions}`, + description: `This is the updated rule: ${response?.data?.fixedInstructions}`, }); - if (response.ruleId) { + if (response?.data?.ruleId) { setFixedInstructions({ - ruleId: response.ruleId, - fixedInstructions: response.fixedInstructions, + ruleId: response.data.ruleId, + fixedInstructions: response.data.fixedInstructions, }); } else { toastError({ @@ -829,7 +851,7 @@ function AIFixForm({ } } }, - [], + [executeAsync], ); return ( @@ -845,7 +867,7 @@ function AIFixForm({ registerProps={register("explanation")} error={errors.explanation} /> - @@ -879,9 +901,23 @@ function SuggestedFix({ showRerunButton: boolean; isTest: boolean; }) { - const [isSaving, setIsSaving] = useState(false); const [accepted, setAccepted] = useState(false); + const { account } = useAccount(); + const { executeAsync, isExecuting } = useAction( + updateRuleInstructionsAction.bind(null, account?.email || ""), + { + onSuccess: () => { + toastSuccess({ description: "Rule updated!" }); + }, + onError: (error) => { + toastError({ + description: error.error.serverError ?? "An error occurred", + }); + }, + }, + ); + return (
@@ -895,27 +931,19 @@ function SuggestedFix({ ) : (
- @@ -949,30 +977,34 @@ function RerunButton({ message: ParsedMessage; isTest: boolean; }) { - const [checking, setChecking] = useState(false); const [result, setResult] = useState(); + const { account } = useAccount(); + const { execute, isExecuting } = useAction( + runRulesAction.bind(null, account?.email || ""), + { + onSuccess: (result) => { + setResult(result?.data); + }, + onError: (error) => { + toastError({ + title: "There was an error testing the email", + description: error.error.serverError ?? "An error occurred", + }); + }, + }, + ); + return ( <> -
*/}
@@ -126,6 +109,8 @@ export function ColdEmailList() { mutate={mutate} selected={selected} onToggleSelect={onToggleSelect} + markNotColdEmail={markNotColdEmail} + isExecuting={isExecuting} /> ))} @@ -146,15 +131,17 @@ function Row({ mutate, selected, onToggleSelect, + markNotColdEmail, + isExecuting, }: { row: ColdEmailsResponse["coldEmails"][number]; userEmail: string; mutate: () => void; selected: Map; onToggleSelect: (id: string) => void; + markNotColdEmail: (input: { sender: string }) => Promise; + isExecuting: boolean; }) { - const [isMarkingColdEmail, setIsMarkingColdEmail] = useState(false); - return ( @@ -186,12 +173,10 @@ function Row({ diff --git a/apps/web/app/(app)/license/page.tsx b/apps/web/app/(app)/license/page.tsx index b5d00ca63..cacb9ebb1 100644 --- a/apps/web/app/(app)/license/page.tsx +++ b/apps/web/app/(app)/license/page.tsx @@ -1,16 +1,16 @@ "use client"; import { useCallback, use } from "react"; +import { useAction } from "next-safe-action/hooks"; import { type SubmitHandler, useForm } from "react-hook-form"; import { Button } from "@/components/Button"; import { Input } from "@/components/Input"; import { TopSection } from "@/components/TopSection"; import { activateLicenseKeyAction } from "@/utils/actions/premium"; import { AlertBasic } from "@/components/Alert"; -import { handleActionResult } from "@/utils/server-action"; import { usePremium } from "@/components/PremiumAlert"; - -type Inputs = { licenseKey: string }; +import { toastError, toastSuccess } from "@/components/Toast"; +import type { ActivateLicenseKeyOptions } from "@/utils/actions/premium.validation"; export default function LicensePage(props: { searchParams: Promise<{ "license-key"?: string }>; @@ -41,16 +41,32 @@ export default function LicensePage(props: { } function ActivateLicenseForm(props: { licenseKey?: string }) { + const { execute: activateLicenseKey, isExecuting } = useAction( + activateLicenseKeyAction, + { + onSuccess: () => { + toastSuccess({ description: "License activated!" }); + }, + onError: () => { + toastError({ description: "Error activating license!" }); + }, + }, + ); + const { register, handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ defaultValues: { licenseKey: props.licenseKey } }); + formState: { errors }, + } = useForm({ + defaultValues: { licenseKey: props.licenseKey }, + }); - const onSubmit: SubmitHandler = useCallback(async (data) => { - const result = await activateLicenseKeyAction(data.licenseKey); - handleActionResult(result, "License activated!"); - }, []); + const onSubmit: SubmitHandler = useCallback( + (data) => { + activateLicenseKey({ licenseKey: data.licenseKey }); + }, + [activateLicenseKey], + ); return (
@@ -61,7 +77,7 @@ function ActivateLicenseForm(props: { licenseKey?: string }) { registerProps={register("licenseKey", { required: true })} error={errors.licenseKey} /> -
diff --git a/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx b/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx index fb364a84c..554077674 100644 --- a/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx +++ b/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx @@ -1,10 +1,10 @@ "use client"; -import { useCallback, useState } from "react"; -import { type SubmitHandler, useForm } from "react-hook-form"; +import { useAction } from "next-safe-action/hooks"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; -import { isActionError } from "@/utils/error"; import { zodResolver } from "@hookform/resolvers/zod"; import { Dialog, @@ -22,8 +22,7 @@ import { createApiKeyAction, deactivateApiKeyAction, } from "@/utils/actions/api-key"; -import { handleActionResult } from "@/utils/server-action"; -import { toastError } from "@/components/Toast"; +import { toastError, toastSuccess } from "@/components/Toast"; import { CopyInput } from "@/components/CopyInput"; import { SectionDescription } from "@/components/Typography"; @@ -49,33 +48,37 @@ export function ApiKeysCreateButtonModal() { } function ApiKeysForm() { + const [secretKey, setSecretKey] = useState(""); + + const { execute, isExecuting } = useAction(createApiKeyAction, { + onSuccess: (result) => { + if (!result?.data?.secretKey) { + toastError({ description: "Failed to create API key" }); + return; + } + + setSecretKey(result.data.secretKey); + toastSuccess({ description: "API key created!" }); + }, + onError: (error) => { + toastError({ + description: + `Failed to create API key. ${error.error.serverError || ""}`.trim(), + }); + }, + }); + const { register, handleSubmit, - formState: { errors, isSubmitting }, + formState: { errors }, } = useForm({ resolver: zodResolver(createApiKeyBody), defaultValues: {}, }); - const [secretKey, setSecretKey] = useState(""); - - const onSubmit: SubmitHandler = useCallback( - async (data) => { - const result = await createApiKeyAction(data); - handleActionResult(result, "API key created!"); - - if (!isActionError(result) && result?.secretKey) { - setSecretKey(result.secretKey); - } else { - toastError({ description: "Failed to create API key" }); - } - }, - [], - ); - return !secretKey ? ( -
+ -
@@ -100,15 +103,20 @@ function ApiKeysForm() { } export function ApiKeysDeactivateButton({ id }: { id: string }) { + const { execute, isExecuting } = useAction(deactivateApiKeyAction, { + onSuccess: () => { + toastSuccess({ description: "API key deactivated!" }); + }, + onError: (error) => { + toastError({ + description: + `Failed to deactivate API key. ${error.error.serverError || ""}`.trim(), + }); + }, + }); + return ( - ); diff --git a/apps/web/app/(app)/settings/MultiAccountSection.tsx b/apps/web/app/(app)/settings/MultiAccountSection.tsx index fce41c7ef..8e34458ed 100644 --- a/apps/web/app/(app)/settings/MultiAccountSection.tsx +++ b/apps/web/app/(app)/settings/MultiAccountSection.tsx @@ -29,7 +29,8 @@ import { PremiumTier } from "@prisma/client"; import { env } from "@/env"; import { getUserTier, isAdminForPremium } from "@/utils/premium"; import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; -import { handleActionResult } from "@/utils/server-action"; +import { useAction } from "next-safe-action/hooks"; +import { toastError, toastSuccess } from "@/components/Toast"; export function MultiAccountSection() { const { data: session } = useSession(); @@ -47,6 +48,20 @@ export function MultiAccountSection() { const { openModal, PremiumModal } = usePremiumModal(); + const { execute: claimPremiumAdmin, isExecuting: isClaimingPremiumAdmin } = + useAction(claimPremiumAdminAction, { + onSuccess: () => { + toastSuccess({ description: "Admin claimed!" }); + mutate(); + }, + onError: (error) => { + toastError({ + description: + `Failed to claim premium admin. ${error.error.serverError || ""}`.trim(), + }); + }, + }); + if ( isPremium && !isAdminForPremium(data?.admins || [], session?.user.id || "") @@ -67,13 +82,7 @@ export function MultiAccountSection() {
{!data?.admins.length && (
-
@@ -128,7 +137,7 @@ function MultiAccountForm({ const { register, handleSubmit, - formState: { errors, isSubmitting }, + formState: { errors }, control, } = useForm({ resolver: zodResolver(saveMultiAccountPremiumBody), @@ -148,17 +157,30 @@ function MultiAccountForm({ const extraSeats = fields.length - emailAccountsAccess - 1; const needsToPurchaseMoreSeats = isLifetime && extraSeats > 0; + const { execute: updateMultiAccountPremium, isExecuting } = useAction( + updateMultiAccountPremiumAction, + { + onSuccess: () => { + toastSuccess({ description: "Users updated!" }); + }, + onError: (error) => { + toastError({ + description: + `Failed to update users. ${error.error.serverError || ""}`.trim(), + }); + }, + }, + ); + const onSubmit: SubmitHandler = useCallback( async (data) => { if (!data.emailAddresses) return; if (needsToPurchaseMoreSeats) return; const emails = data.emailAddresses.map((e) => e.email); - const result = await updateMultiAccountPremiumAction(emails); - - handleActionResult(result, "Users updated!"); + updateMultiAccountPremium({ emails }); }, - [needsToPurchaseMoreSeats], + [needsToPurchaseMoreSeats, updateMultiAccountPremium], ); return ( @@ -191,7 +213,7 @@ function MultiAccountForm({
{needsToPurchaseMoreSeats ? ( - ) : ( - )} diff --git a/apps/web/app/(app)/stats/NewsletterModal.tsx b/apps/web/app/(app)/stats/NewsletterModal.tsx index 0509621ae..bc22538c5 100644 --- a/apps/web/app/(app)/stats/NewsletterModal.tsx +++ b/apps/web/app/(app)/stats/NewsletterModal.tsx @@ -3,7 +3,7 @@ import { BarChart } from "@tremor/react"; import type { DateRange } from "react-day-picker"; import Link from "next/link"; import { ExternalLinkIcon } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { usePostHog } from "posthog-js/react"; import { Dialog, DialogContent, @@ -19,18 +19,17 @@ import type { ZodPeriod } from "@inboxzero/tinybird"; import { LoadingContent } from "@/components/LoadingContent"; import { SectionHeader } from "@/components/Typography"; import { EmailList } from "@/components/email-list/EmailList"; -import type { ThreadsResponse } from "@/app/api/google/threads/controller"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { getGmailFilterSettingsUrl } from "@/utils/url"; import { Tooltip } from "@/components/Tooltip"; import { AlertBasic } from "@/components/Alert"; -import { onAutoArchive } from "@/utils/actions/client"; import { MoreDropdown } from "@/app/(app)/bulk-unsubscribe/common"; import { useLabels } from "@/hooks/useLabels"; import type { Row } from "@/app/(app)/bulk-unsubscribe/types"; -import { usePostHog } from "posthog-js/react"; import { useThreads } from "@/hooks/useThreads"; +import { useAccount } from "@/providers/AccountProvider"; +import { onAutoArchive } from "@/utils/actions/client"; export function NewsletterModal(props: { newsletter?: Pick; @@ -39,8 +38,7 @@ export function NewsletterModal(props: { }) { const { newsletter, refreshInterval, onClose } = props; - const session = useSession(); - const email = session.data?.user.email; + const { email } = useAccount(); const { userLabels } = useLabels(); @@ -69,7 +67,9 @@ export function NewsletterModal(props: { diff --git a/apps/web/components/ActionButtons.tsx b/apps/web/components/ActionButtons.tsx index 2c76e2c39..951b3877d 100644 --- a/apps/web/components/ActionButtons.tsx +++ b/apps/web/components/ActionButtons.tsx @@ -10,6 +10,7 @@ import { ButtonGroup } from "@/components/ButtonGroup"; import { LoadingMiniSpinner } from "@/components/Loading"; import { getGmailUrl } from "@/utils/url"; import { onTrashThread } from "@/utils/actions/client"; +import { useAccount } from "@/providers/AccountProvider"; export function ActionButtons({ threadId, @@ -26,8 +27,7 @@ export function ActionButtons({ onArchive: () => void; refetch: (threadId?: string) => void; }) { - const session = useSession(); - const email = session.data?.user.email; + const { email } = useAccount(); const openInGmail = useCallback(() => { // open in gmail @@ -41,10 +41,10 @@ export function ActionButtons({ // TODO show loading toast const onTrash = useCallback(async () => { setIsTrashing(true); - await onTrashThread(threadId); + await onTrashThread({ email, threadId }); refetch(threadId); setIsTrashing(false); - }, [threadId, refetch]); + }, [threadId, refetch, email]); const buttons = useMemo( () => [ diff --git a/apps/web/providers/AccountProvider.tsx b/apps/web/providers/AccountProvider.tsx index 549305726..fb8bfa01f 100644 --- a/apps/web/providers/AccountProvider.tsx +++ b/apps/web/providers/AccountProvider.tsx @@ -8,6 +8,7 @@ type Account = GetAccountsResponse["accounts"][number]; type AccountContext = { account: Account | undefined; + email: string; isLoading: boolean; setAccountId: (newId: string) => Promise; }; @@ -51,7 +52,9 @@ export function AccountProvider({ children }: { children: React.ReactNode }) { }, [data, accountId]); return ( - + {children} ); diff --git a/apps/web/utils/actions/api-key.ts b/apps/web/utils/actions/api-key.ts index a8fa69b9f..5721ead3f 100644 --- a/apps/web/utils/actions/api-key.ts +++ b/apps/web/utils/actions/api-key.ts @@ -7,9 +7,9 @@ import { } from "@/utils/actions/api-key.validation"; import prisma from "@/utils/prisma"; import { generateSecureToken, hashApiKey } from "@/utils/api-key"; -import { actionClient } from "@/utils/actions/safe-action"; +import { actionClientUser } from "@/utils/actions/safe-action"; -export const createApiKeyAction = actionClient +export const createApiKeyAction = actionClientUser .metadata({ name: "createApiKey" }) .schema(createApiKeyBody) .action(async ({ ctx: { userId }, parsedInput: { name } }) => { @@ -30,7 +30,7 @@ export const createApiKeyAction = actionClient return { secretKey }; }); -export const deactivateApiKeyAction = actionClient +export const deactivateApiKeyAction = actionClientUser .metadata({ name: "deactivateApiKey" }) .schema(deactivateApiKeyBody) .action(async ({ ctx: { userId }, parsedInput: { id } }) => { diff --git a/apps/web/utils/actions/client.ts b/apps/web/utils/actions/client.ts index 555f01cbe..a168523fa 100644 --- a/apps/web/utils/actions/client.ts +++ b/apps/web/utils/actions/client.ts @@ -1,25 +1,72 @@ +import { toastSuccess, toastError } from "@/components/Toast"; import { createAutoArchiveFilterAction, deleteFilterAction, + trashThreadAction, } from "@/utils/actions/mail"; -import { trashMessageAction, trashThreadAction } from "@/utils/actions/mail"; -import { handleActionResult } from "@/utils/server-action"; -export async function onAutoArchive(from: string, gmailLabelId?: string) { - const result = await createAutoArchiveFilterAction(from, gmailLabelId); - handleActionResult(result, "Auto archive enabled!"); -} +export async function onAutoArchive({ + email, + from, + gmailLabelId, +}: { + email: string; + from: string; + gmailLabelId?: string; +}) { + const result = await createAutoArchiveFilterAction(email, { + from, + gmailLabelId, + }); -export async function onDeleteFilter(filterId: string) { - const result = await deleteFilterAction(filterId); - handleActionResult(result, "Auto archive disabled!"); + if (result?.serverError) { + toastError({ + description: + `There was an error enabling auto archive. ${result.serverError || ""}`.trim(), + }); + } else { + toastSuccess({ + description: "Auto archive enabled!", + }); + } } -export async function onTrashThread(threadId: string) { - const result = await trashThreadAction(threadId); - handleActionResult(result, "Thread deleted!"); +export async function onDeleteFilter({ + email, + filterId, +}: { + email: string; + filterId: string; +}) { + const result = await deleteFilterAction(email, { id: filterId }); + if (result?.serverError) { + toastError({ + description: + `There was an error disabling auto archive. ${result.serverError || ""}`.trim(), + }); + } else { + toastSuccess({ + description: "Auto archive disabled!", + }); + } } -export async function onTrashMessage(messageId: string) { - const result = await trashMessageAction(messageId); - handleActionResult(result, "Message deleted!"); + +export async function onTrashThread({ + email, + threadId, +}: { + email: string; + threadId: string; +}) { + const result = await trashThreadAction(email, { threadId }); + if (result?.serverError) { + toastError({ + description: + `There was an error deleting the thread. ${result.serverError || ""}`.trim(), + }); + } else { + toastSuccess({ + description: "Thread deleted!", + }); + } } diff --git a/apps/web/utils/actions/error-messages.ts b/apps/web/utils/actions/error-messages.ts index f42d110e1..072374bc0 100644 --- a/apps/web/utils/actions/error-messages.ts +++ b/apps/web/utils/actions/error-messages.ts @@ -2,9 +2,9 @@ import { revalidatePath } from "next/cache"; import { clearUserErrorMessages } from "@/utils/error-messages"; -import { actionClient } from "@/utils/actions/safe-action"; +import { actionClientUser } from "@/utils/actions/safe-action"; -export const clearUserErrorMessagesAction = actionClient +export const clearUserErrorMessagesAction = actionClientUser .metadata({ name: "clearUserErrorMessages" }) .action(async ({ ctx: { userId } }) => { await clearUserErrorMessages({ userId }); diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index 4117201c2..c34d46803 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -56,16 +56,16 @@ export const trashThreadAction = actionClient if (!isStatusOk(res.status)) return { error: "Failed to delete thread" }; }); -export const trashMessageAction = actionClient - .metadata({ name: "trashMessage" }) - .schema(z.object({ messageId: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { messageId } }) => { - const gmail = await getGmailClientForEmail({ email }); +// export const trashMessageAction = actionClient +// .metadata({ name: "trashMessage" }) +// .schema(z.object({ messageId: z.string() })) +// .action(async ({ ctx: { email }, parsedInput: { messageId } }) => { +// const gmail = await getGmailClientForEmail({ email }); - const res = await trashMessage({ gmail, messageId }); +// const res = await trashMessage({ gmail, messageId }); - if (!isStatusOk(res.status)) return { error: "Failed to delete message" }; - }); +// if (!isStatusOk(res.status)) return { error: "Failed to delete message" }; +// }); export const markReadThreadAction = actionClient .metadata({ name: "markReadThread" }) diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index 249d95cab..dfc382ab7 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -16,13 +16,17 @@ import { import { PremiumTier } from "@prisma/client"; import { ONE_MONTH_MS, ONE_YEAR_MS } from "@/utils/date"; import { getVariantId } from "@/app/(app)/premium/config"; -import { actionClient, adminActionClient } from "@/utils/actions/safe-action"; +import { + actionClientUser, + adminActionClient, +} from "@/utils/actions/safe-action"; +import { activateLicenseKeySchema } from "@/utils/actions/premium.validation"; -export const decrementUnsubscribeCreditAction = actionClient +export const decrementUnsubscribeCreditAction = actionClientUser .metadata({ name: "decrementUnsubscribeCredit" }) - .action(async ({ ctx: { email, userId } }) => { + .action(async ({ ctx: { userId } }) => { const user = await prisma.user.findUnique({ - where: { email }, + where: { id: userId }, select: { premium: { select: { @@ -74,7 +78,7 @@ const updateMultiAccountPremiumSchema = z.object({ emails: z.array(z.string()), }); -export const updateMultiAccountPremiumAction = actionClient +export const updateMultiAccountPremiumAction = actionClientUser .metadata({ name: "updateMultiAccountPremium" }) .schema(updateMultiAccountPremiumSchema) .action(async ({ ctx: { userId }, parsedInput: { emails } }) => { @@ -165,7 +169,7 @@ export const updateMultiAccountPremiumAction = actionClient }); }); -export const switchPremiumPlanAction = actionClient +export const switchPremiumPlanAction = actionClientUser .metadata({ name: "switchPremiumPlan" }) .schema(z.object({ premiumTier: z.nativeEnum(PremiumTier) })) .action(async ({ ctx: { userId }, parsedInput: { premiumTier } }) => { @@ -196,9 +200,9 @@ async function createPremiumForUser(userId: string) { }); } -export const activateLicenseKeyAction = actionClient +export const activateLicenseKeyAction = actionClientUser .metadata({ name: "activateLicenseKey" }) - .schema(z.object({ licenseKey: z.string() })) + .schema(activateLicenseKeySchema) .action(async ({ ctx: { userId }, parsedInput: { licenseKey } }) => { const lemonSqueezyLicense = await activateLemonLicenseKey( licenseKey, @@ -327,7 +331,7 @@ export const changePremiumStatusAction = adminActionClient }, ); -export const claimPremiumAdminAction = actionClient +export const claimPremiumAdminAction = actionClientUser .metadata({ name: "claimPremiumAdmin" }) .action(async ({ ctx: { userId } }) => { const user = await prisma.user.findUnique({ diff --git a/apps/web/utils/actions/premium.validation.ts b/apps/web/utils/actions/premium.validation.ts new file mode 100644 index 000000000..299cdb9e1 --- /dev/null +++ b/apps/web/utils/actions/premium.validation.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const activateLicenseKeySchema = z.object({ + licenseKey: z.string(), +}); +export type ActivateLicenseKeyOptions = z.infer< + typeof activateLicenseKeySchema +>; diff --git a/apps/web/utils/actions/safe-action.ts b/apps/web/utils/actions/safe-action.ts index 4f7681191..4d029c66e 100644 --- a/apps/web/utils/actions/safe-action.ts +++ b/apps/web/utils/actions/safe-action.ts @@ -10,15 +10,16 @@ import { isAdmin } from "@/utils/admin"; const logger = createScopedLogger("safe-action"); -export const actionClient = createSafeActionClient({ +const baseClient = createSafeActionClient({ defineMetadataSchema() { return z.object({ name: z.string() }); }, -}) - .use(async ({ next, metadata }) => { - logger.info("Calling action", { name: metadata?.name }); - return next(); - }) +}).use(async ({ next, metadata }) => { + logger.info("Calling action", { name: metadata?.name }); + return next(); +}); + +export const actionClient = baseClient .bindArgsSchemas<[activeEmail: z.ZodString]>([z.string()]) .use(async ({ next, metadata, bindArgsClientInputs }) => { const session = await auth(); @@ -44,7 +45,7 @@ export const actionClient = createSafeActionClient({ ctx: { userId, userEmail, - session, + // session, email, emailAccount, }, @@ -52,22 +53,29 @@ export const actionClient = createSafeActionClient({ }); }); -export const adminActionClient = createSafeActionClient({ - defineMetadataSchema() { - return z.object({ name: z.string() }); - }, -}) - .use(async ({ next, metadata }) => { - logger.info("Calling action", { name: metadata?.name }); - return next(); - }) - .use(async ({ next, metadata }) => { - const session = await auth(); - if (!session?.user) throw new Error("Unauthorized"); - if (!isAdmin({ email: session.user.email })) - throw new Error("Unauthorized"); +// doesn't bind to a specific email +export const actionClientUser = baseClient.use(async ({ next, metadata }) => { + const session = await auth(); - return withServerActionInstrumentation(metadata?.name, async () => { - return next({ ctx: {} }); + if (!session?.user) throw new Error("Unauthorized"); + const userEmail = session.user.email; + if (!userEmail) throw new Error("Unauthorized"); + + const userId = session.user.id; + + return withServerActionInstrumentation(metadata?.name, async () => { + return next({ + ctx: { userId }, }); }); +}); + +export const adminActionClient = baseClient.use(async ({ next, metadata }) => { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!isAdmin({ email: session.user.email })) throw new Error("Unauthorized"); + + return withServerActionInstrumentation(metadata?.name, async () => { + return next({ ctx: {} }); + }); +}); diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index 7d81950ce..34ca9d1f1 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -6,11 +6,11 @@ import { signOut } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { deleteUser } from "@/utils/user/delete"; import { extractGmailSignature } from "@/utils/gmail/signature"; -import { getGmailClient } from "@/utils/gmail/client"; import { getMessage, getMessages } from "@/utils/gmail/message"; import { parseMessage } from "@/utils/mail"; import { GmailLabel } from "@/utils/gmail/label"; -import { actionClient } from "@/utils/actions/safe-action"; +import { actionClient, actionClientUser } from "@/utils/actions/safe-action"; +import { getGmailClientForEmail } from "@/utils/account"; const saveAboutBody = z.object({ about: z.string().max(2_000) }); export type SaveAboutBody = z.infer; @@ -44,9 +44,9 @@ export const saveSignatureAction = actionClient export const loadSignatureFromGmailAction = actionClient .metadata({ name: "loadSignatureFromGmail" }) - .action(async ({ ctx: { session } }) => { + .action(async ({ ctx: { email } }) => { // 1. find last 5 sent emails - const gmail = getGmailClient(session); + const gmail = await getGmailClientForEmail({ email }); const messages = await getMessages(gmail, { query: "from:me", maxResults: 5, @@ -77,7 +77,7 @@ export const resetAnalyticsAction = actionClient }); }); -export const deleteAccountAction = actionClient +export const deleteAccountAction = actionClientUser .metadata({ name: "deleteAccount" }) .action(async ({ ctx: { userId } }) => { try { @@ -87,7 +87,7 @@ export const deleteAccountAction = actionClient await deleteUser({ userId }); }); -export const completedOnboardingAction = actionClient +export const completedOnboardingAction = actionClientUser .metadata({ name: "completedOnboarding" }) .action(async ({ ctx: { userId } }) => { await prisma.user.update({ @@ -96,7 +96,7 @@ export const completedOnboardingAction = actionClient }); }); -export const completedAppOnboardingAction = actionClient +export const completedAppOnboardingAction = actionClientUser .metadata({ name: "completedAppOnboarding" }) .action(async ({ ctx: { userId } }) => { await prisma.user.update({ @@ -111,7 +111,7 @@ const saveOnboardingAnswersBody = z.object({ answers: z.any(), }); -export const saveOnboardingAnswersAction = actionClient +export const saveOnboardingAnswersAction = actionClientUser .metadata({ name: "saveOnboardingAnswers" }) .schema(saveOnboardingAnswersBody) .action( diff --git a/apps/web/utils/server-action.ts b/apps/web/utils/server-action.ts index bd438f1ea..21ac08c06 100644 --- a/apps/web/utils/server-action.ts +++ b/apps/web/utils/server-action.ts @@ -1,6 +1,5 @@ "use client"; -import { toastError, toastSuccess } from "@/components/Toast"; import { type ActionError, type ServerActionResponse, @@ -8,17 +7,6 @@ import { isActionError, } from "@/utils/error"; -export function handleActionResult( - result: ServerActionResponse, - successMessage: string, -) { - if (isActionError(result)) { - toastError({ description: result.error }); - } else { - toastSuccess({ description: successMessage }); - } -} - // NOTE: not in love with the indirection here // Not sure I'll use across the app export async function handleActionCall< From 6f49c262df27b7f80b88eb86ec3c917595d5d833 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:25:04 +0300 Subject: [PATCH 050/176] reply tracker action --- apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx b/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx index 2c3fa2639..0f5451f2d 100644 --- a/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx +++ b/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx @@ -20,9 +20,10 @@ import { markOnboardingAsCompleted, REPLY_ZERO_ONBOARDING_COOKIE, } from "@/utils/cookies"; - +import { useAccount } from "@/providers/AccountProvider"; export function EnableReplyTracker({ enabled }: { enabled: boolean }) { const router = useRouter(); + const { email } = useAccount(); return ( { - processPreviousSentEmailsAction(); + processPreviousSentEmailsAction(email); router.push("/reply-zero?enabled=true"); }, From 4b4a66ab8e52044cf769241e91299a88de414562 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:30:07 +0300 Subject: [PATCH 051/176] clean up use account email --- apps/web/app/(app)/assess.tsx | 8 +-- .../app/(app)/automation/ReportMistake.tsx | 49 +++++++++---------- apps/web/app/(app)/automation/Rules.tsx | 8 +-- .../cold-email-blocker/ColdEmailList.tsx | 9 ++-- .../(app)/reply-zero/EnableReplyTracker.tsx | 1 + .../app/(app)/settings/AboutSectionForm.tsx | 6 +-- apps/web/app/(app)/settings/DeleteSection.tsx | 6 +-- .../(app)/settings/SignatureSectionForm.tsx | 6 +-- .../app/(app)/settings/WebhookGenerate.tsx | 4 +- 9 files changed, 46 insertions(+), 51 deletions(-) diff --git a/apps/web/app/(app)/assess.tsx b/apps/web/app/(app)/assess.tsx index b275885e6..35d5e855f 100644 --- a/apps/web/app/(app)/assess.tsx +++ b/apps/web/app/(app)/assess.tsx @@ -10,15 +10,15 @@ import { import { useAccount } from "@/providers/AccountProvider"; export function AssessUser() { - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync: executeAssessAsync } = useAction( - assessAction.bind(null, account?.email || ""), + assessAction.bind(null, email), ); const { execute: executeWhitelistInboxZero } = useAction( - whitelistInboxZeroAction.bind(null, account?.email || ""), + whitelistInboxZeroAction.bind(null, email), ); const { execute: executeAnalyzeWritingStyle } = useAction( - analyzeWritingStyleAction.bind(null, account?.email || ""), + analyzeWritingStyleAction.bind(null, email), ); useEffect(() => { diff --git a/apps/web/app/(app)/automation/ReportMistake.tsx b/apps/web/app/(app)/automation/ReportMistake.tsx index 87d86e5de..6d404eea6 100644 --- a/apps/web/app/(app)/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/automation/ReportMistake.tsx @@ -160,9 +160,9 @@ function Content({ }); }, []); - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync, isExecuting } = useAction( - reportAiMistakeAction.bind(null, account?.email || ""), + reportAiMistakeAction.bind(null, email), ); const onSelectExpectedRule = useCallback( @@ -455,9 +455,9 @@ function GroupMismatchAdd({ onBack: () => void; onClose: () => void; }) { - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync, isExecuting } = useAction( - addGroupItemAction.bind(null, account?.email || ""), + addGroupItemAction.bind(null, email), ); return ( @@ -524,9 +524,9 @@ function GroupMismatchRemove({ onBack: () => void; onClose: () => void; }) { - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync, isExecuting } = useAction( - deleteGroupItemAction.bind(null, account?.email || ""), + deleteGroupItemAction.bind(null, email), ); return ( @@ -726,9 +726,9 @@ function RuleForm({ }: { rule: Pick & { instructions: string }; }) { - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync, isExecuting } = useAction( - updateRuleInstructionsAction.bind(null, account?.email || ""), + updateRuleInstructionsAction.bind(null, email), ); const { @@ -794,9 +794,9 @@ function AIFixForm({ fixedInstructions: string; }>(); - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync, isExecuting } = useAction( - reportAiMistakeAction.bind(null, account?.email || ""), + reportAiMistakeAction.bind(null, email), ); const { @@ -903,9 +903,9 @@ function SuggestedFix({ }) { const [accepted, setAccepted] = useState(false); - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync, isExecuting } = useAction( - updateRuleInstructionsAction.bind(null, account?.email || ""), + updateRuleInstructionsAction.bind(null, email), { onSuccess: () => { toastSuccess({ description: "Rule updated!" }); @@ -979,21 +979,18 @@ function RerunButton({ }) { const [result, setResult] = useState(); - const { account } = useAccount(); - const { execute, isExecuting } = useAction( - runRulesAction.bind(null, account?.email || ""), - { - onSuccess: (result) => { - setResult(result?.data); - }, - onError: (error) => { - toastError({ - title: "There was an error testing the email", - description: error.error.serverError ?? "An error occurred", - }); - }, + const { email } = useAccount(); + const { execute, isExecuting } = useAction(runRulesAction.bind(null, email), { + onSuccess: (result) => { + setResult(result?.data); }, - ); + onError: (error) => { + toastError({ + title: "There was an error testing the email", + description: error.error.serverError ?? "An error occurred", + }); + }, + }); return ( <> diff --git a/apps/web/app/(app)/automation/Rules.tsx b/apps/web/app/(app)/automation/Rules.tsx index b3e0df36e..3b09998ec 100644 --- a/apps/web/app/(app)/automation/Rules.tsx +++ b/apps/web/app/(app)/automation/Rules.tsx @@ -51,15 +51,15 @@ export function Rules() { const hasRules = !!data?.length; - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync: setRuleRunOnThreads } = useAction( - setRuleRunOnThreadsAction.bind(null, account?.email || ""), + setRuleRunOnThreadsAction.bind(null, email), ); const { executeAsync: setRuleEnabled } = useAction( - setRuleEnabledAction.bind(null, account?.email || ""), + setRuleEnabledAction.bind(null, email), ); const { executeAsync: deleteRule } = useAction( - deleteRuleAction.bind(null, account?.email || ""), + deleteRuleAction.bind(null, email), ); return ( diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx index 92d10f431..cb95ebf76 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx @@ -37,15 +37,12 @@ export function ColdEmailList() { `/api/user/cold-email?page=${page}`, ); - const session = useSession(); - const userEmail = session.data?.user?.email || ""; - const { selected, isAllSelected, onToggleSelect, onToggleSelectAll } = useToggleSelect(data?.coldEmails || []); - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync: markNotColdEmail, isExecuting } = useAction( - markNotColdEmailAction.bind(null, account?.email || ""), + markNotColdEmailAction.bind(null, email), { onSuccess: () => { toastSuccess({ description: "Marked not cold email!" }); @@ -105,7 +102,7 @@ export function ColdEmailList() { { defaultValues: { about: about ?? "" }, }); - const { account } = useAccount(); - + const { email } = useAccount(); + const { execute, isExecuting } = useAction( - saveAboutAction.bind(null, account?.email || ""), + saveAboutAction.bind(null, email), { onSuccess: () => { toastSuccess({ diff --git a/apps/web/app/(app)/settings/DeleteSection.tsx b/apps/web/app/(app)/settings/DeleteSection.tsx index c0ce6a571..1163220c9 100644 --- a/apps/web/app/(app)/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/settings/DeleteSection.tsx @@ -16,12 +16,12 @@ import { useAccount } from "@/providers/AccountProvider"; export function DeleteSection() { const { onCancelLoadBatch } = useStatLoader(); - const { account } = useAccount(); + const { email } = useAccount(); const { executeAsync: executeResetAnalytics } = useAction( - resetAnalyticsAction.bind(null, account?.email || ""), + resetAnalyticsAction.bind(null, email), ); const { executeAsync: executeDeleteAccount } = useAction( - deleteAccountAction.bind(null, ""), + deleteAccountAction.bind(null), ); return ( diff --git a/apps/web/app/(app)/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/settings/SignatureSectionForm.tsx index c306da333..a697c95f0 100644 --- a/apps/web/app/(app)/settings/SignatureSectionForm.tsx +++ b/apps/web/app/(app)/settings/SignatureSectionForm.tsx @@ -34,9 +34,9 @@ export const SignatureSectionForm = ({ const editorRef = useRef(null); - const { account } = useAccount(); + const { email } = useAccount(); const { execute, isExecuting } = useAction( - saveSignatureAction.bind(null, account?.email || ""), + saveSignatureAction.bind(null, email), { onSuccess: () => { toastSuccess({ description: "Signature saved" }); @@ -49,7 +49,7 @@ export const SignatureSectionForm = ({ }, ); const { executeAsync: executeLoadSignatureFromGmail } = useAction( - loadSignatureFromGmailAction.bind(null, account?.email || ""), + loadSignatureFromGmailAction.bind(null, email), ); const handleEditorChange = useCallback( diff --git a/apps/web/app/(app)/settings/WebhookGenerate.tsx b/apps/web/app/(app)/settings/WebhookGenerate.tsx index c19ff008b..c2fc91a43 100644 --- a/apps/web/app/(app)/settings/WebhookGenerate.tsx +++ b/apps/web/app/(app)/settings/WebhookGenerate.tsx @@ -7,9 +7,9 @@ import { useAccount } from "@/providers/AccountProvider"; import { useAction } from "next-safe-action/hooks"; export function RegenerateSecretButton({ hasSecret }: { hasSecret: boolean }) { - const { account } = useAccount(); + const { email } = useAccount(); const { execute, isExecuting } = useAction( - regenerateWebhookSecretAction.bind(null, account?.email || ""), + regenerateWebhookSecretAction.bind(null, email), { onSuccess: () => { toastSuccess({ From dc967c8fa66af2ef6991762c86a35af371e6983b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:32:43 +0300 Subject: [PATCH 052/176] unsub action --- apps/web/app/(app)/automation/RuleForm.tsx | 36 ++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index d76019e37..ba83f0b49 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -67,12 +67,14 @@ import { Tooltip } from "@/components/Tooltip"; import { createGroupAction } from "@/utils/actions/group"; import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; import { Badge } from "@/components/Badge"; - +import { useAccount } from "@/providers/AccountProvider"; export function RuleForm({ rule, }: { rule: CreateRuleBody & { id?: string }; }) { + const { email } = useAccount(); + const { register, handleSubmit, @@ -128,7 +130,7 @@ export function RuleForm({ (label) => label.name === action.label, ); if (!hasLabel && action.label?.value && !action.label?.ai) { - await createLabelAction({ name: action.label.value }); + await createLabelAction(email, { name: action.label.value }); } } } @@ -143,12 +145,12 @@ export function RuleForm({ } if (data.id) { - const res = await updateRuleAction({ ...data, id: data.id }); + const res = await updateRuleAction(email, { ...data, id: data.id }); if (isActionError(res)) { console.error(res); toastError({ description: res.error }); - } else if (!res.rule) { + } else if (!res?.data?.rule) { toastError({ description: "There was an error updating the rule.", }); @@ -163,12 +165,12 @@ export function RuleForm({ router.push("/automation?tab=rules"); } } else { - const res = await createRuleAction(data); + const res = await createRuleAction(email, data); if (isActionError(res)) { console.error(res); toastError({ description: res.error }); - } else if (!res.rule) { + } else if (!res?.data?.rule) { toastError({ description: "There was an error creating the rule.", }); @@ -180,12 +182,12 @@ export function RuleForm({ automate: data.automate, runOnThreads: data.runOnThreads, }); - router.replace(`/automation/rule/${res.rule.id}`); + router.replace(`/automation/rule/${res.data.rule.id}`); router.push("/automation?tab=rules"); } } }, - [userLabels, router, posthog], + [userLabels, router, posthog, email], ); const conditions = watch("conditions"); @@ -315,17 +317,19 @@ export function RuleForm({ onClick={async () => { if (!rule.id) return; - const result = await createGroupAction({ ruleId: rule.id }); + const result = await createGroupAction(email, { + ruleId: rule.id, + }); if (isActionError(result)) { toastError({ description: result.error }); - } else if (!result.groupId) { + } else if (!result?.data?.groupId) { toastError({ description: "There was an error setting up learned patterns.", }); } else { - setLearnedPatternGroupId(result.groupId); + setLearnedPatternGroupId(result.data.groupId); } }} > @@ -662,6 +666,7 @@ export function RuleForm({ userLabels={userLabels} isLoading={isLoading} mutate={mutate} + userEmail={email} /> ); })} @@ -751,12 +756,14 @@ function LabelCombobox({ userLabels, isLoading, mutate, + userEmail, }: { value: string; onChangeValue: (value: string) => void; userLabels: NonNullable; isLoading: boolean; mutate: () => void; + userEmail: string; }) { const [search, setSearch] = useState(""); @@ -781,7 +788,9 @@ function LabelCombobox({ onClick={() => { toast.promise( async () => { - const res = await createLabelAction({ name: search }); + const res = await createLabelAction(userEmail, { + name: search, + }); mutate(); if (isActionError(res)) throw new Error(res.error); }, @@ -830,6 +839,7 @@ function ActionField({ userLabels, isLoading, mutate, + userEmail, }: { field: { name: "label" | "subject" | "content" | "to" | "cc" | "bcc" | "url"; @@ -847,6 +857,7 @@ function ActionField({ userLabels: NonNullable; isLoading: boolean; mutate: () => void; + userEmail: string; }) { // Get the typed field value safely const getFieldValue = (fieldName: string): string => { @@ -896,6 +907,7 @@ function ActionField({ onChangeValue={(newValue: string) => { setValue(`actions.${i}.${field.name}.value`, newValue); }} + userEmail={userEmail} />
) : isDraftContent && !setManually ? ( From 83a7c3dc7a026ed0dcafb58132609e6314444206 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:50:48 +0300 Subject: [PATCH 053/176] actions --- apps/web/app/(app)/bulk-unsubscribe/hooks.ts | 29 +++-- apps/web/components/email-list/EmailList.tsx | 48 +++++---- apps/web/store/QueueInitializer.tsx | 9 +- apps/web/store/archive-queue.ts | 107 +++++++++++++------ 4 files changed, 130 insertions(+), 63 deletions(-) diff --git a/apps/web/app/(app)/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/bulk-unsubscribe/hooks.ts index 1b195a850..3a0348343 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/hooks.ts +++ b/apps/web/app/(app)/bulk-unsubscribe/hooks.ts @@ -434,7 +434,11 @@ export function useBulkArchive({ return { onBulkArchive }; } -async function deleteAllFromSender(name: string, onFinish: () => void) { +async function deleteAllFromSender( + name: string, + onFinish: () => void, + userEmail: string, +) { toast.promise( async () => { // 1. search gmail for messages from sender @@ -444,14 +448,15 @@ async function deleteAllFromSender(name: string, onFinish: () => void) { // 2. delete messages if (data?.length) { await new Promise((resolve, reject) => { - deleteEmails( - data.map((t) => t.id).filter(isDefined), - () => { + deleteEmails({ + threadIds: data.map((t) => t.id).filter(isDefined), + onSuccess: () => { onFinish(); resolve(); }, - reject, - ); + onError: reject, + email: userEmail, + }); }); } @@ -471,9 +476,11 @@ async function deleteAllFromSender(name: string, onFinish: () => void) { export function useDeleteAllFromSender({ item, posthog, + userEmail, }: { item: T; posthog: PostHog; + userEmail: string; }) { const [deleteAllLoading, setDeleteAllLoading] = React.useState(false); @@ -482,7 +489,11 @@ export function useDeleteAllFromSender({ posthog.capture("Clicked Delete All"); - await deleteAllFromSender(item.name, () => setDeleteAllLoading(false)); + await deleteAllFromSender( + item.name, + () => setDeleteAllLoading(false), + userEmail, + ); }; return { @@ -494,15 +505,17 @@ export function useDeleteAllFromSender({ export function useBulkDelete({ mutate, posthog, + userEmail, }: { mutate: () => Promise; posthog: PostHog; + userEmail: string; }) { const onBulkDelete = async (items: T[]) => { posthog.capture("Clicked Bulk Delete"); for (const item of items) { - await deleteAllFromSender(item.name, mutate); + await deleteAllFromSender(item.name, mutate, userEmail); } }; diff --git a/apps/web/components/email-list/EmailList.tsx b/apps/web/components/email-list/EmailList.tsx index a2cf7b117..5732fd458 100644 --- a/apps/web/components/email-list/EmailList.tsx +++ b/apps/web/components/email-list/EmailList.tsx @@ -32,6 +32,7 @@ import { deleteEmails, markReadThreads, } from "@/store/archive-queue"; +import { useAccount } from "@/providers/AccountProvider"; export function List({ emails, @@ -175,7 +176,7 @@ export function EmailList({ isLoadingMore?: boolean; handleLoadMore?: () => void; }) { - const session = useSession(); + const { email } = useAccount(); // if right panel is open const [openThreadId, setOpenThreadId] = useQueryState("thread-id"); const closePanel = useCallback( @@ -220,15 +221,15 @@ export function EmailList({ toast.promise( async () => { await new Promise((resolve, reject) => { - archiveEmails( + archiveEmails({ threadIds, - undefined, - (threadId) => { - refetch({ removedThreadIds: [threadId] }); + onSuccess: () => { + refetch({ removedThreadIds: [thread.id] }); resolve(); }, - reject, - ); + onError: reject, + email, + }); }); }, { @@ -238,7 +239,7 @@ export function EmailList({ }, ); }, - [refetch], + [refetch, email], ); const listRef = useRef(null); @@ -319,15 +320,15 @@ export function EmailList({ .map(([id]) => id); await new Promise((resolve, reject) => { - archiveEmails( + archiveEmails({ threadIds, - undefined, - () => { + onSuccess: () => { refetch({ removedThreadIds: threadIds }); resolve(); }, - reject, - ); + onError: reject, + email, + }); }); }, { @@ -336,7 +337,7 @@ export function EmailList({ error: "There was an error archiving the emails :(", }, ); - }, [selectedRows, refetch]); + }, [selectedRows, refetch, email]); const onTrashBulk = useCallback(async () => { toast.promise( @@ -346,14 +347,15 @@ export function EmailList({ .map(([id]) => id); await new Promise((resolve, reject) => { - deleteEmails( + deleteEmails({ threadIds, - () => { + onSuccess: () => { refetch({ removedThreadIds: threadIds }); resolve(); }, - reject, - ); + onError: reject, + email, + }); }); }, { @@ -362,7 +364,7 @@ export function EmailList({ error: "There was an error deleting the emails :(", }, ); - }, [selectedRows, refetch]); + }, [selectedRows, refetch, email]); const onPlanAiBulk = useCallback(async () => { toast.promise( @@ -450,7 +452,11 @@ export function EmailList({ if (!alreadyOpen) scrollToId(thread.id); - markReadThreads([thread.id], () => refetch()); + markReadThreads({ + threadIds: [thread.id], + onSuccess: () => refetch(), + email, + }); }; return ( @@ -464,7 +470,7 @@ export function EmailList({ map.delete(thread.id); } }} - userEmailAddress={session.data?.user.email || ""} + userEmailAddress={email} thread={thread} opened={openThreadId === thread.id} closePanel={closePanel} diff --git a/apps/web/store/QueueInitializer.tsx b/apps/web/store/QueueInitializer.tsx index be59aadd1..904bd5a76 100644 --- a/apps/web/store/QueueInitializer.tsx +++ b/apps/web/store/QueueInitializer.tsx @@ -2,20 +2,25 @@ import { useEffect } from "react"; import { processQueue, useQueueState } from "@/store/archive-queue"; +import { useAccount } from "@/providers/AccountProvider"; let isInitialized = false; function useInitializeQueues() { const queueState = useQueueState(); + const { email } = useAccount(); useEffect(() => { if (!isInitialized) { isInitialized = true; if (queueState.activeThreads) { - processQueue({ threads: queueState.activeThreads }); + processQueue({ + threads: queueState.activeThreads, + email, + }); } } - }, [queueState.activeThreads]); + }, [queueState.activeThreads, email]); } export function QueueInitializer() { diff --git a/apps/web/store/archive-queue.ts b/apps/web/store/archive-queue.ts index a2aa8f9f4..173cb7e3c 100644 --- a/apps/web/store/archive-queue.ts +++ b/apps/web/store/archive-queue.ts @@ -7,7 +7,7 @@ import { trashThreadAction, markReadThreadAction, } from "@/utils/actions/mail"; -import { isActionError, type ServerActionResponse } from "@/utils/error"; +import { isActionError } from "@/utils/error"; import { exponentialBackoff, sleep } from "@/utils/sleep"; import { useAtomValue } from "jotai"; @@ -52,17 +52,13 @@ export function useQueueState() { return useAtomValue(queueAtom); } -type ActionFunction = ( - threadId: string, - labelId?: string, -) => Promise>; - -const actionMap: Record = { - archive: (threadId: string, labelId?: string) => - archiveThreadAction(threadId, labelId), - delete: trashThreadAction, - markRead: (threadId: string) => markReadThreadAction(threadId, true), -}; +type ActionFunction = ({ + threadId, + labelId, +}: { + threadId: string; + labelId?: string; +}) => Promise; const addThreadsToQueue = ({ actionType, @@ -70,12 +66,14 @@ const addThreadsToQueue = ({ labelId, onSuccess, onError, + email, }: { actionType: ActionType; threadIds: string[]; labelId?: string; onSuccess?: (threadId: string) => void; onError?: (threadId: string) => void; + email: string; }) => { const threads = Object.fromEntries( threadIds.map((threadId) => [ @@ -92,38 +90,70 @@ const addThreadsToQueue = ({ totalThreads: prev.totalThreads + Object.keys(threads).length, })); - processQueue({ threads, onSuccess, onError }); + processQueue({ threads, onSuccess, onError, email }); }; -export const archiveEmails = async ( - threadIds: string[], - labelId: string | undefined, - onSuccess: (threadId: string) => void, - onError?: (threadId: string) => void, -) => { +export const archiveEmails = async ({ + threadIds, + labelId, + onSuccess, + onError, + email, +}: { + threadIds: string[]; + labelId?: string; + onSuccess: (threadId: string) => void; + onError?: (threadId: string) => void; + email: string; +}) => { addThreadsToQueue({ actionType: "archive", threadIds, labelId, onSuccess, onError, + email, }); }; -export const markReadThreads = async ( - threadIds: string[], - onSuccess: (threadId: string) => void, - onError?: (threadId: string) => void, -) => { - addThreadsToQueue({ actionType: "markRead", threadIds, onSuccess, onError }); +export const markReadThreads = async ({ + threadIds, + onSuccess, + onError, + email, +}: { + threadIds: string[]; + onSuccess: (threadId: string) => void; + onError?: (threadId: string) => void; + email: string; +}) => { + addThreadsToQueue({ + actionType: "markRead", + threadIds, + onSuccess, + onError, + email, + }); }; -export const deleteEmails = async ( - threadIds: string[], - onSuccess: (threadId: string) => void, - onError?: (threadId: string) => void, -) => { - addThreadsToQueue({ actionType: "delete", threadIds, onSuccess, onError }); +export const deleteEmails = async ({ + threadIds, + onSuccess, + onError, + email, +}: { + threadIds: string[]; + onSuccess: (threadId: string) => void; + onError?: (threadId: string) => void; + email: string; +}) => { + addThreadsToQueue({ + actionType: "delete", + threadIds, + onSuccess, + onError, + email, + }); }; function removeThreadFromQueue(threadId: string, actionType: ActionType) { @@ -146,11 +176,21 @@ export function processQueue({ threads, onSuccess, onError, + email, }: { threads: Record; onSuccess?: (threadId: string) => void; onError?: (threadId: string) => void; + email: string; }) { + const actionMap: Record = { + archive: ({ threadId, labelId }) => + archiveThreadAction(email, { threadId, labelId }), + delete: ({ threadId }) => trashThreadAction(email, { threadId }), + markRead: ({ threadId }) => + markReadThreadAction(email, { threadId, read: true }), + }; + emailActionQueue.addAll( Object.entries(threads).map( ([_key, { threadId, actionType, labelId }]) => @@ -162,7 +202,10 @@ export function processQueue({ `Queue: ${actionType}. Processing ${threadId}${attemptCount > 1 ? ` (attempt ${attemptCount})` : ""}`, ); - const result = await actionMap[actionType](threadId, labelId); + const result = await actionMap[actionType]({ + threadId, + labelId, + }); // when Gmail API returns a rate limit error, throw an error so it can be retried if (isActionError(result)) { From efc71632ca3b6f4724a286af0e82eb9f5f4a4ace Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:56:32 +0300 Subject: [PATCH 054/176] remove usesessions --- apps/web/app/(app)/automation/History.tsx | 6 ++--- apps/web/app/(app)/automation/Pending.tsx | 22 ++++++++++++------- .../web/app/(app)/automation/ProcessRules.tsx | 11 +++++----- .../cold-email-blocker/ColdEmailRejected.tsx | 5 ++--- .../(app)/cold-email-blocker/TestRules.tsx | 10 ++++----- apps/web/app/(app)/debug/drafts/page.tsx | 4 ++-- apps/web/app/(app)/premium/Pricing.tsx | 6 ++--- .../(app)/smart-categories/Uncategorized.tsx | 16 +++++++++----- apps/web/app/(app)/stats/EmailAnalytics.tsx | 5 ++--- apps/web/components/GroupedTable.tsx | 10 +++++---- 10 files changed, 53 insertions(+), 42 deletions(-) diff --git a/apps/web/app/(app)/automation/History.tsx b/apps/web/app/(app)/automation/History.tsx index 79f0167d7..5aded29c7 100644 --- a/apps/web/app/(app)/automation/History.tsx +++ b/apps/web/app/(app)/automation/History.tsx @@ -2,7 +2,6 @@ import useSWR from "swr"; import { useQueryState, parseAsInteger, parseAsString } from "nuqs"; -import { useSession } from "next-auth/react"; import { LoadingContent } from "@/components/LoadingContent"; import type { PlanHistoryResponse } from "@/app/api/user/planned/history/route"; import { AlertBasic } from "@/components/Alert"; @@ -24,6 +23,7 @@ import { import { TablePagination } from "@/components/TablePagination"; import { Badge } from "@/components/Badge"; import { RulesSelect } from "@/app/(app)/automation/RulesSelect"; +import { useAccount } from "@/providers/AccountProvider"; export function History() { const [page] = useQueryState("page", parseAsInteger.withDefault(1)); @@ -35,7 +35,7 @@ export function History() { const { data, isLoading, error } = useSWR( `/api/user/planned/history?page=${page}&ruleId=${ruleId}`, ); - const session = useSession(); + const { email } = useAccount(); return ( <> @@ -48,7 +48,7 @@ export function History() { ) : ( @@ -56,7 +57,7 @@ export function Pending() { ) : ( @@ -90,7 +91,7 @@ function PendingTable({ for (const id of Array.from(selected.keys())) { const p = pending.find((p) => p.id === id); if (!p) continue; - const result = await approvePlanAction({ + const result = await approvePlanAction(userEmail, { executedRuleId: id, message: p.message, }); @@ -102,13 +103,15 @@ function PendingTable({ mutate(); } setIsApproving(false); - }, [selected, pending, mutate]); + }, [selected, pending, mutate, userEmail]); const rejectSelected = useCallback(async () => { setIsRejecting(true); for (const id of Array.from(selected.keys())) { const p = pending.find((p) => p.id === id); if (!p) continue; - const result = await rejectPlanAction({ executedRuleId: id }); + const result = await rejectPlanAction(userEmail, { + executedRuleId: id, + }); if (isActionError(result)) { toastError({ description: `Error rejecting action. ${result.error}` || "", @@ -117,7 +120,7 @@ function PendingTable({ mutate(); } setIsRejecting(false); - }, [selected, pending, mutate]); + }, [selected, pending, mutate, userEmail]); return (
@@ -223,6 +226,7 @@ function ExecuteButtons({ }) { const [isApproving, setIsApproving] = useState(false); const [isRejecting, setIsRejecting] = useState(false); + const { email } = useAccount(); return (
@@ -230,7 +234,7 @@ function ExecuteButtons({ variant="default" onClick={async () => { setIsApproving(true); - const result = await approvePlanAction({ + const result = await approvePlanAction(email, { executedRuleId: id, message, }); @@ -252,7 +256,9 @@ function ExecuteButtons({ variant="outline" onClick={async () => { setIsRejecting(true); - const result = await rejectPlanAction({ executedRuleId: id }); + const result = await rejectPlanAction(email, { + executedRuleId: id, + }); if (isActionError(result)) { toastError({ description: `Error rejecting action. ${result.error}` || "", diff --git a/apps/web/app/(app)/automation/ProcessRules.tsx b/apps/web/app/(app)/automation/ProcessRules.tsx index 0fc4072a9..5b6cafde6 100644 --- a/apps/web/app/(app)/automation/ProcessRules.tsx +++ b/apps/web/app/(app)/automation/ProcessRules.tsx @@ -3,7 +3,6 @@ import { useCallback, useState, useRef, useMemo } from "react"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; -import { useSession } from "next-auth/react"; import { parseAsBoolean, useQueryState } from "nuqs"; import { BookOpenCheckIcon, @@ -39,6 +38,7 @@ import { cn } from "@/utils"; import { TestCustomEmailForm } from "@/app/(app)/automation/TestCustomEmailForm"; import { ProcessResultDisplay } from "@/app/(app)/automation/ProcessResultDisplay"; import { Tooltip } from "@/components/Tooltip"; +import { useAccount } from "@/providers/AccountProvider"; type Message = MessagesResponse["messages"][number]; @@ -77,8 +77,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { }, [data]); const { data: rules } = useSWR("/api/user/rules"); - const session = useSession(); - const email = session.data?.user.email; + const { email: userEmail } = useAccount(); // only show test rules form if we have an AI rule. this form won't match group/static rules which will confuse users const hasAiRules = rules?.some( @@ -100,7 +99,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { async (message: Message, rerun?: boolean) => { setIsRunning((prev) => ({ ...prev, [message.id]: true })); - const result = await runRulesAction({ + const result = await runRulesAction(userEmail, { messageId: message.id, threadId: message.threadId, isTest: testMode, @@ -116,7 +115,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { } setIsRunning((prev) => ({ ...prev, [message.id]: false })); }, - [testMode], + [testMode, userEmail], ); const handleRunAll = async () => { @@ -219,7 +218,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { onRun(message, rerun)} diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailRejected.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailRejected.tsx index aef9cbe6b..ea13bfe9e 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailRejected.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailRejected.tsx @@ -1,7 +1,6 @@ "use client"; import useSWR from "swr"; -import { useSession } from "next-auth/react"; import { LoadingContent } from "@/components/LoadingContent"; import type { ColdEmailsResponse } from "@/app/api/user/cold-email/route"; import { @@ -19,6 +18,7 @@ import { useSearchParams } from "next/navigation"; import { ColdEmailStatus } from "@prisma/client"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { EmailMessageCellWithData } from "@/components/EmailMessageCell"; +import { useAccount } from "@/providers/AccountProvider"; export function ColdEmailRejected() { const searchParams = useSearchParams(); @@ -27,8 +27,7 @@ export function ColdEmailRejected() { `/api/user/cold-email?page=${page}&status=${ColdEmailStatus.USER_REJECTED_COLD}`, ); - const session = useSession(); - const userEmail = session.data?.user?.email || ""; + const { email: userEmail } = useAccount(); return ( diff --git a/apps/web/app/(app)/cold-email-blocker/TestRules.tsx b/apps/web/app/(app)/cold-email-blocker/TestRules.tsx index 1634f921b..b516c3030 100644 --- a/apps/web/app/(app)/cold-email-blocker/TestRules.tsx +++ b/apps/web/app/(app)/cold-email-blocker/TestRules.tsx @@ -7,7 +7,6 @@ import { useCallback, useState } from "react"; import { type SubmitHandler, useForm } from "react-hook-form"; import useSWR from "swr"; import { SparklesIcon } from "lucide-react"; -import { useSession } from "next-auth/react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; import { toastError } from "@/components/Toast"; @@ -22,6 +21,7 @@ import { TableCell, TableRow } from "@/components/ui/table"; import { CardContent } from "@/components/ui/card"; import { testColdEmailAction } from "@/utils/actions/cold-email"; import type { ColdEmailBlockerBody } from "@/utils/actions/cold-email.validation"; +import { useAccount } from "@/providers/AccountProvider"; export function TestRulesContent() { const [searchQuery, setSearchQuery] = useState(""); @@ -33,8 +33,7 @@ export function TestRulesContent() { }, ); - const session = useSession(); - const email = session.data?.user.email; + const { email: userEmail } = useAccount(); return (
@@ -59,7 +58,7 @@ export function TestRulesContent() { ); })} @@ -217,11 +216,12 @@ function useColdEmailTest() { const [response, setResponse] = useState( null, ); + const { email: userEmail } = useAccount(); const testEmail = async (data: ColdEmailBlockerBody) => { setTesting(true); try { - const res = await testColdEmailAction(data); + const res = await testColdEmailAction(userEmail, data); if (isActionError(res)) { toastError({ title: "Error checking whether it's a cold email.", diff --git a/apps/web/app/(app)/debug/drafts/page.tsx b/apps/web/app/(app)/debug/drafts/page.tsx index efeae80d1..60778b666 100644 --- a/apps/web/app/(app)/debug/drafts/page.tsx +++ b/apps/web/app/(app)/debug/drafts/page.tsx @@ -20,6 +20,7 @@ import { Badge } from "@/components/ui/badge"; import { useMessagesBatch } from "@/hooks/useMessagesBatch"; import { LoadingMiniSpinner } from "@/components/Loading"; import { isDefined } from "@/utils/types"; +import { useAccount } from "@/providers/AccountProvider"; export default function DebugDraftsPage() { const { data, isLoading, error } = useSWR( @@ -37,8 +38,7 @@ export default function DebugDraftsPage() { parseReplies: true, }); - const session = useSession(); - const userEmail = session.data?.user?.email || ""; + const { email: userEmail } = useAccount(); return (
diff --git a/apps/web/app/(app)/premium/Pricing.tsx b/apps/web/app/(app)/premium/Pricing.tsx index 6ed1918c9..36c2a8b67 100644 --- a/apps/web/app/(app)/premium/Pricing.tsx +++ b/apps/web/app/(app)/premium/Pricing.tsx @@ -254,9 +254,9 @@ export function Pricing(props: { if (premiumTier) { toast.promise( async () => { - const result = await switchPremiumPlanAction( - tier.tiers[frequency.value], - ); + const result = await switchPremiumPlanAction({ + premiumTier: tier.tiers[frequency.value], + }); if (isActionError(result)) throw new Error(result.error); }, diff --git a/apps/web/app/(app)/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/smart-categories/Uncategorized.tsx index 48f2080be..b6388e9bf 100644 --- a/apps/web/app/(app)/smart-categories/Uncategorized.tsx +++ b/apps/web/app/(app)/smart-categories/Uncategorized.tsx @@ -3,7 +3,6 @@ import useSWRInfinite from "swr/infinite"; import { useMemo, useCallback } from "react"; import { ChevronsDownIcon, SparklesIcon, StopCircleIcon } from "lucide-react"; -import { useSession } from "next-auth/react"; import { ClientOnly } from "@/components/ClientOnly"; import { SendersTable } from "@/components/GroupedTable"; import { LoadingContent } from "@/components/LoadingContent"; @@ -24,6 +23,7 @@ import { Toggle } from "@/components/Toggle"; import { setAutoCategorizeAction } from "@/utils/actions/categorize"; import { TooltipExplanation } from "@/components/TooltipExplanation"; import type { CategoryWithRules } from "@/utils/category.server"; +import { useAccount } from "@/providers/AccountProvider"; export function Uncategorized({ categories, @@ -46,8 +46,7 @@ export function Uncategorized({ [senderAddresses], ); - const session = useSession(); - const userEmail = session.data?.user?.email || ""; + const { email: userEmail } = useAccount(); return ( @@ -94,7 +93,10 @@ export function Uncategorized({ text="Automatically categorize new senders when they email you" />
- +
@@ -135,8 +137,10 @@ export function Uncategorized({ function AutoCategorizeToggle({ autoCategorizeSenders, + userEmail, }: { autoCategorizeSenders: boolean; + userEmail: string; }) { return ( { - await setAutoCategorizeAction({ autoCategorizeSenders: enabled }); + await setAutoCategorizeAction(userEmail, { + autoCategorizeSenders: enabled, + }); }} /> ); diff --git a/apps/web/app/(app)/stats/EmailAnalytics.tsx b/apps/web/app/(app)/stats/EmailAnalytics.tsx index 5999addb8..3563851a3 100644 --- a/apps/web/app/(app)/stats/EmailAnalytics.tsx +++ b/apps/web/app/(app)/stats/EmailAnalytics.tsx @@ -11,13 +11,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { BarList } from "@/components/charts/BarList"; import { getDateRangeParams } from "@/app/(app)/stats/params"; import { getGmailSearchUrl } from "@/utils/url"; - +import { useAccount } from "@/providers/AccountProvider"; export function EmailAnalytics(props: { dateRange?: DateRange | undefined; refreshInterval: number; }) { - const session = useSession(); - const email = session.data?.user.email; + const { email } = useAccount(); const params = getDateRangeParams(props.dateRange); diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 92a0e71aa..09bc292a0 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -59,6 +59,7 @@ import { import type { CategoryWithRules } from "@/utils/category.server"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { CategorySelect } from "@/components/CategorySelect"; +import { useAccount } from "@/providers/AccountProvider"; const COLUMNS = 4; @@ -75,8 +76,7 @@ export function GroupedTable({ emailGroups: EmailGroup[]; categories: CategoryWithRules[]; }) { - const session = useSession(); - const userEmail = session.data?.user?.email || ""; + const { email: userEmail } = useAccount(); const categoryMap = useMemo(() => { return categories.reduce>( @@ -161,7 +161,7 @@ export function GroupedTable({ ({ const { deleteAllLoading, onDeleteAll } = useDeleteAllFromSender({ item, posthog, + userEmail, }); return ( @@ -430,10 +430,10 @@ export function MoreDropdown({ from: item.name, gmailLabelId: label.id, }); - if (isActionError(res)) { + if (res?.serverError) { toastError({ title: "Error", - description: `Failed to add ${item.name} to ${label.name}. ${res.error}`, + description: `Failed to add ${item.name} to ${label.name}. ${res.serverError || ""}`, }); } else { toastSuccess({ @@ -516,7 +516,7 @@ export function HeaderButton(props: { // value: sender, // }); -// if (isActionError(result)) { +// if (result?.serverError) { // toastError({ // description: `Failed to add ${sender} to ${group.name}. ${result.error}`, // }); diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx index cb95ebf76..d0e92e9cb 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx @@ -3,7 +3,6 @@ import { useAction } from "next-safe-action/hooks"; import { useCallback } from "react"; import useSWR from "swr"; -import { useSession } from "next-auth/react"; import { CircleXIcon } from "lucide-react"; import { LoadingContent } from "@/components/LoadingContent"; import type { ColdEmailsResponse } from "@/app/api/user/cold-email/route"; diff --git a/apps/web/app/(app)/debug/drafts/page.tsx b/apps/web/app/(app)/debug/drafts/page.tsx index 60778b666..2d2093746 100644 --- a/apps/web/app/(app)/debug/drafts/page.tsx +++ b/apps/web/app/(app)/debug/drafts/page.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import { useSession } from "next-auth/react"; import useSWR from "swr"; import { Card, CardContent } from "@/components/ui/card"; import { PageHeading, TypographyP } from "@/components/Typography"; diff --git a/apps/web/app/(app)/premium/Pricing.tsx b/apps/web/app/(app)/premium/Pricing.tsx index 36c2a8b67..7051519e2 100644 --- a/apps/web/app/(app)/premium/Pricing.tsx +++ b/apps/web/app/(app)/premium/Pricing.tsx @@ -257,8 +257,8 @@ export function Pricing(props: { const result = await switchPremiumPlanAction({ premiumTier: tier.tiers[frequency.value], }); - if (isActionError(result)) - throw new Error(result.error); + if (result?.serverError) + throw new Error(result.serverError); }, { loading: "Switching to plan...", diff --git a/apps/web/app/(app)/settings/DeleteSection.tsx b/apps/web/app/(app)/settings/DeleteSection.tsx index 1163220c9..262571e37 100644 --- a/apps/web/app/(app)/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/settings/DeleteSection.tsx @@ -9,7 +9,6 @@ import { resetAnalyticsAction, } from "@/utils/actions/user"; import { logOut } from "@/utils/user"; -import { isActionError } from "@/utils/error"; import { useStatLoader } from "@/providers/StatLoaderProvider"; import { useAccount } from "@/providers/AccountProvider"; @@ -63,7 +62,7 @@ export function DeleteSection() { async () => { const result = await executeDeleteAccount(); await logOut("/"); - if (isActionError(result)) throw new Error(result.error); + if (result?.serverError) throw new Error(result.serverError); }, { loading: "Deleting account...", diff --git a/apps/web/app/(app)/settings/LabelsSection.tsx b/apps/web/app/(app)/settings/LabelsSection.tsx index fe43eb440..0d8459543 100644 --- a/apps/web/app/(app)/settings/LabelsSection.tsx +++ b/apps/web/app/(app)/settings/LabelsSection.tsx @@ -35,6 +35,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { useAccount } from "@/providers/AccountProvider"; const recommendedLabels = ["Newsletter", "Receipt", "Calendar"]; @@ -120,6 +121,8 @@ function LabelsSectionFormInner(props: { .find((l) => l.indexOf(label.toLowerCase()) > -1), ); + const { email } = useAccount(); + return ( { - const res = await createLabelAction({ name: label }); + const res = await createLabelAction(email, { + name: label, + }); if (isErrorMessage(res)) { toastError({ title: `Failed to create label "${label}"`, @@ -298,6 +303,8 @@ export function LabelItem(props: { function AddLabelModal() { const [isOpen, setIsOpen] = useState(false); + const { email } = useAccount(); + const { mutate } = useSWRConfig(); const { @@ -311,7 +318,7 @@ function AddLabelModal() { async (data) => { const { name, description } = data; try { - await createLabelAction({ name, description }); + await createLabelAction(email, { name, description }); toastSuccess({ description: `Label "${name}" created!`, @@ -331,7 +338,7 @@ function AddLabelModal() { }); } }, - [mutate], + [mutate, email], ); return ( diff --git a/apps/web/app/(app)/smart-categories/CreateCategoryButton.tsx b/apps/web/app/(app)/smart-categories/CreateCategoryButton.tsx index 1a081e71b..a947a38c3 100644 --- a/apps/web/app/(app)/smart-categories/CreateCategoryButton.tsx +++ b/apps/web/app/(app)/smart-categories/CreateCategoryButton.tsx @@ -12,7 +12,6 @@ import { createCategoryBody, type CreateCategoryBody, } from "@/utils/actions/categorize.validation"; -import { isActionError } from "@/utils/error"; import { createCategoryAction } from "@/utils/actions/categorize"; import { Dialog, @@ -22,6 +21,7 @@ import { } from "@/components/ui/dialog"; import type { Category } from "@prisma/client"; import { MessageText } from "@/components/Typography"; +import { useAccount } from "@/providers/AccountProvider"; type ExampleCategory = { name: string; @@ -143,6 +143,8 @@ function CreateCategoryForm({ category?: Pick & { id?: string }; closeModal: () => void; }) { + const { email } = useAccount(); + const { register, handleSubmit, @@ -167,18 +169,18 @@ function CreateCategoryForm({ const onSubmit: SubmitHandler = useCallback( async (data) => { - const result = await createCategoryAction(data); + const result = await createCategoryAction(email, data); - if (isActionError(result)) { + if (result?.serverError) { toastError({ - description: `There was an error creating the category. ${result.error}`, + description: `There was an error creating the category. ${result.serverError || ""}`, }); } else { toastSuccess({ description: "Category created!" }); closeModal(); } }, - [closeModal], + [closeModal, email], ); return ( diff --git a/apps/web/app/(app)/stats/EmailAnalytics.tsx b/apps/web/app/(app)/stats/EmailAnalytics.tsx index 3563851a3..155dd2d62 100644 --- a/apps/web/app/(app)/stats/EmailAnalytics.tsx +++ b/apps/web/app/(app)/stats/EmailAnalytics.tsx @@ -1,7 +1,6 @@ "use client"; import useSWR from "swr"; -import { useSession } from "next-auth/react"; import type { DateRange } from "react-day-picker"; import { useExpanded } from "@/app/(app)/stats/useExpanded"; import type { RecipientsResponse } from "@/app/api/user/stats/recipients/route"; diff --git a/apps/web/components/ActionButtons.tsx b/apps/web/components/ActionButtons.tsx index 951b3877d..7a4023a64 100644 --- a/apps/web/components/ActionButtons.tsx +++ b/apps/web/components/ActionButtons.tsx @@ -1,5 +1,4 @@ import { useCallback, useMemo, useState } from "react"; -import { useSession } from "next-auth/react"; import { ArchiveIcon, Trash2Icon, diff --git a/apps/web/components/CategorySelect.tsx b/apps/web/components/CategorySelect.tsx index 111a8acd3..2a58d354d 100644 --- a/apps/web/components/CategorySelect.tsx +++ b/apps/web/components/CategorySelect.tsx @@ -10,7 +10,6 @@ import { } from "@/components/ui/select"; import { changeSenderCategoryAction } from "@/utils/actions/categorize"; import { toastError, toastSuccess } from "@/components/Toast"; -import { isActionError } from "@/utils/error"; import { useAiCategorizationQueueItem } from "@/store/ai-categorize-sender-queue"; import { LoadingMiniSpinner } from "@/components/Loading"; @@ -45,7 +44,7 @@ export function CategorySelect({ categoryId: value, }); - if (isActionError(result)) { + if (result?.serverError) { toastError({ description: result.error }); } else { toastSuccess({ description: "Category changed" }); diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 09bc292a0..60f2781fb 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { Fragment, useMemo } from "react"; import { useQueryState } from "nuqs"; -import { useSession } from "next-auth/react"; import groupBy from "lodash/groupBy"; import { useReactTable, @@ -41,7 +40,6 @@ import { removeAllFromCategoryAction, } from "@/utils/actions/categorize"; import { toastError, toastSuccess } from "@/components/Toast"; -import { isActionError } from "@/utils/error"; import { Button } from "@/components/ui/button"; import { addToArchiveSenderQueue, @@ -166,8 +164,8 @@ export function GroupedTable({ categoryId: value, }); - if (isActionError(result)) { - toastError({ description: result.error }); + if (result?.serverError) { + toastError({ description: result.serverError }); } else { toastSuccess({ description: "Category changed" }); } @@ -227,8 +225,8 @@ export function GroupedTable({ categoryName, }); - if (isActionError(result)) { - toastError({ description: result.error }); + if (result?.serverError) { + toastError({ description: result.serverError }); } else { toastSuccess({ description: "All emails removed from category", diff --git a/apps/web/components/email-list/EmailList.tsx b/apps/web/components/email-list/EmailList.tsx index 5732fd458..acf9c945c 100644 --- a/apps/web/components/email-list/EmailList.tsx +++ b/apps/web/components/email-list/EmailList.tsx @@ -9,7 +9,6 @@ import { toast } from "sonner"; import { ChevronsDownIcon } from "lucide-react"; import { ActionButtonsBulk } from "@/components/ActionButtonsBulk"; import { Celebration } from "@/components/Celebration"; -import { useSession } from "next-auth/react"; import { EmailPanel } from "@/components/email-list/EmailPanel"; import type { Thread } from "@/components/email-list/types"; import { useExecutePlan } from "@/components/email-list/PlanActions"; diff --git a/apps/web/components/email-list/EmailMessage.tsx b/apps/web/components/email-list/EmailMessage.tsx index 5cd29e1b8..9ae3109bd 100644 --- a/apps/web/components/email-list/EmailMessage.tsx +++ b/apps/web/components/email-list/EmailMessage.tsx @@ -22,9 +22,9 @@ import type { ThreadMessage } from "@/components/email-list/types"; import { EmailDetails } from "@/components/email-list/EmailDetails"; import { HtmlEmail, PlainEmail } from "@/components/email-list/EmailContents"; import { EmailAttachments } from "@/components/email-list/EmailAttachments"; -import { isActionError } from "@/utils/error"; import { Loading } from "@/components/Loading"; import { MessageText } from "@/components/Typography"; +import { useAccount } from "@/providers/AccountProvider"; export function EmailMessage({ message, @@ -204,6 +204,8 @@ function ReplyPanel({ draftMessage?: ThreadMessage; generateNudge?: boolean; }) { + const { email } = useAccount(); + const replyRef = useRef(null); const [isGeneratingReply, setIsGeneratingReply] = useState(false); @@ -227,7 +229,7 @@ function ReplyPanel({ setIsGeneratingReply(true); - const result = await generateNudgeReplyAction({ + const result = await generateNudgeReplyAction(email, { messages: [ { id: message.id, @@ -240,18 +242,18 @@ function ReplyPanel({ }, ], }); - if (isActionError(result)) { + if (result?.serverError) { console.error(result); setReply(""); } else { - setReply(result.text); + setReply(result?.data?.text || ""); } setIsGeneratingReply(false); } // Only generate a nudge if there's no draft message and generateNudge is true if (generateNudge && !draftMessage) generateReply(); - }, [generateNudge, message, draftMessage]); + }, [generateNudge, message, draftMessage, email]); const replyingToEmail: ReplyingToEmail = useMemo(() => { if (showReply) { diff --git a/apps/web/components/email-list/PlanActions.tsx b/apps/web/components/email-list/PlanActions.tsx index 01df8e41b..fbb382206 100644 --- a/apps/web/components/email-list/PlanActions.tsx +++ b/apps/web/components/email-list/PlanActions.tsx @@ -6,11 +6,12 @@ import { Tooltip } from "@/components/Tooltip"; import type { Executing, Thread } from "@/components/email-list/types"; import { cn } from "@/utils"; import { approvePlanAction, rejectPlanAction } from "@/utils/actions/ai-rule"; -import { isActionError } from "@/utils/error"; +import { useAccount } from "@/providers/AccountProvider"; export function useExecutePlan(refetch: () => void) { const [executingPlan, setExecutingPlan] = useState({}); const [rejectingPlan, setRejectingPlan] = useState({}); + const { email } = useAccount(); const executePlan = useCallback( async (thread: Thread) => { @@ -20,13 +21,13 @@ export function useExecutePlan(refetch: () => void) { const lastMessage = thread.messages?.[thread.messages.length - 1]; - const result = await approvePlanAction({ + const result = await approvePlanAction(email, { executedRuleId: thread.plan.id, message: lastMessage, }); - if (isActionError(result)) { + if (result?.serverError) { toastError({ - description: `Unable to execute plan. ${result.error || ""}`, + description: `Unable to execute plan. ${result.serverError || ""}`, }); } else { toastSuccess({ description: "Executed!" }); @@ -36,7 +37,7 @@ export function useExecutePlan(refetch: () => void) { setExecutingPlan((s) => ({ ...s, [thread.id!]: false })); }, - [refetch], + [refetch, email], ); const rejectPlan = useCallback( @@ -44,12 +45,12 @@ export function useExecutePlan(refetch: () => void) { setRejectingPlan((s) => ({ ...s, [thread.id!]: true })); if (thread.plan?.id) { - const result = await rejectPlanAction({ + const result = await rejectPlanAction(email, { executedRuleId: thread.plan.id, }); - if (isActionError(result)) { + if (result?.serverError) { toastError({ - description: `Error rejecting plan. ${result.error || ""}`, + description: `Error rejecting plan. ${result.serverError || ""}`, }); } else { toastSuccess({ description: "Plan rejected" }); @@ -62,7 +63,7 @@ export function useExecutePlan(refetch: () => void) { setRejectingPlan((s) => ({ ...s, [thread.id!]: false })); }, - [refetch], + [refetch, email], ); return { diff --git a/apps/web/store/archive-queue.ts b/apps/web/store/archive-queue.ts index 173cb7e3c..5ff7c0bdd 100644 --- a/apps/web/store/archive-queue.ts +++ b/apps/web/store/archive-queue.ts @@ -7,7 +7,6 @@ import { trashThreadAction, markReadThreadAction, } from "@/utils/actions/mail"; -import { isActionError } from "@/utils/error"; import { exponentialBackoff, sleep } from "@/utils/sleep"; import { useAtomValue } from "jotai"; @@ -208,7 +207,7 @@ export function processQueue({ }); // when Gmail API returns a rate limit error, throw an error so it can be retried - if (isActionError(result)) { + if (result?.serverError) { await sleep(exponentialBackoff(attemptCount, 1_000)); throw new Error(result.error); } diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index fe052fe7d..dfca15a8a 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -538,8 +538,8 @@ export const generateRulesPromptAction = actionClient userLabels: labelsWithCounts.map((label) => label.label), }); - if (isActionError(result)) return { error: result.error }; - if (!result) return { error: "Error generating rules prompt" }; + if (isActionError(result)) throw new SafeError(result.error); + if (!result) throw new SafeError("Error generating rules prompt"); return { rulesPrompt: result.join("\n\n") }; }); @@ -562,10 +562,10 @@ export const reportAiMistakeAction = actionClient ctx: { email, emailAccount }, parsedInput: { expectedRuleId, actualRuleId, explanation, message }, }) => { - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); if (!expectedRuleId && !actualRuleId) - return { error: "Either correct or incorrect rule ID is required" }; + throw new SafeError("Either correct or incorrect rule ID is required"); const [expectedRule, actualRule, user] = await Promise.all([ expectedRuleId @@ -582,12 +582,12 @@ export const reportAiMistakeAction = actionClient ]); if (expectedRuleId && !expectedRule) - return { error: "Expected rule not found" }; + throw new SafeError("Expected rule not found"); if (actualRuleId && !actualRule) - return { error: "Actual rule not found" }; + throw new SafeError("Actual rule not found"); - if (!user) return { error: "User not found" }; + if (!user) throw new SafeError("User not found"); const content = emailToContent({ textHtml: message.textHtml || undefined, @@ -607,8 +607,8 @@ export const reportAiMistakeAction = actionClient explanation: explanation?.trim() || undefined, }); - if (isActionError(result)) return { error: result.error }; - if (!result) return { error: "Error fixing rule" }; + if (isActionError(result)) throw new SafeError(result.error); + if (!result) throw new SafeError("Error fixing rule"); return { ruleId: diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 185ac1594..210368f79 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -30,8 +30,6 @@ const logger = createScopedLogger("actions/categorize"); export const bulkCategorizeSendersAction = actionClient .metadata({ name: "bulkCategorizeSenders" }) .action(async ({ ctx: { email } }) => { - const gmail = await getGmailClientForEmail({ email }); - const userResult = await validateUserAndAiAccess({ email }); if (isActionError(userResult)) return userResult; diff --git a/apps/web/utils/actions/safe-action.ts b/apps/web/utils/actions/safe-action.ts index 6d3247510..c21089c79 100644 --- a/apps/web/utils/actions/safe-action.ts +++ b/apps/web/utils/actions/safe-action.ts @@ -54,7 +54,7 @@ export const actionClient = baseClient ctx: { userId, userEmail, - // session, + session, email, emailAccount, }, diff --git a/apps/web/utils/server-action.ts b/apps/web/utils/server-action.ts deleted file mode 100644 index 21ac08c06..000000000 --- a/apps/web/utils/server-action.ts +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { - type ActionError, - type ServerActionResponse, - captureException, - isActionError, -} from "@/utils/error"; - -// NOTE: not in love with the indirection here -// Not sure I'll use across the app -export async function handleActionCall< - T, - E extends object = Record, ->( - actionName: string, - actionFn: () => Promise>, -): Promise> { - let result: ServerActionResponse; - - try { - result = await actionFn(); - } catch (error) { - captureException(error, { extra: { actionName } }); - return { error: String(error) } as ActionError; - } - - if (isActionError(result)) return result; - - if (!result) { - captureException("The request did not complete", { extra: { actionName } }); - return { error: "The request did not complete" } as ActionError; - } - - return result; -} From 780112d0ac8265b593d489ee095b53ee77142e38 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 01:29:45 +0300 Subject: [PATCH 057/176] fix --- .../(app)/automation/TestCustomEmailForm.tsx | 13 ++++++----- .../app/(app)/automation/group/ViewGroup.tsx | 22 ++++++++++++------- .../automation/knowledge/KnowledgeBase.tsx | 12 ++++++---- .../automation/knowledge/KnowledgeForm.tsx | 12 +++++----- .../onboarding/draft-replies/page.tsx | 12 +++++----- .../rule/[ruleId]/examples/example-list.tsx | 13 ++++++----- apps/web/app/(app)/clean/ConfirmationStep.tsx | 11 +++++----- .../web/app/(app)/clean/EmailFirehoseItem.tsx | 16 ++++++++------ apps/web/app/(app)/clean/PreviewBatch.tsx | 10 ++++----- .../ColdEmailPromptForm.tsx | 11 +++++----- .../cold-email-blocker/ColdEmailSettings.tsx | 10 +++++---- apps/web/utils/actions/ai-rule.ts | 4 ++-- apps/web/utils/actions/clean.ts | 5 +++-- 13 files changed, 87 insertions(+), 64 deletions(-) diff --git a/apps/web/app/(app)/automation/TestCustomEmailForm.tsx b/apps/web/app/(app)/automation/TestCustomEmailForm.tsx index 39dba3467..130ba80c0 100644 --- a/apps/web/app/(app)/automation/TestCustomEmailForm.tsx +++ b/apps/web/app/(app)/automation/TestCustomEmailForm.tsx @@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; import { toastError } from "@/components/Toast"; import { testAiCustomContentAction } from "@/utils/actions/ai-rule"; -import { isActionError } from "@/utils/error"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; import { ProcessResultDisplay } from "@/app/(app)/automation/ProcessResultDisplay"; import { @@ -15,9 +14,11 @@ import { type TestAiCustomContentBody, } from "@/utils/actions/ai-rule.validation"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useAccount } from "@/providers/AccountProvider"; export const TestCustomEmailForm = () => { const [testResult, setTestResult] = useState(); + const { email } = useAccount(); const { register, @@ -29,17 +30,17 @@ export const TestCustomEmailForm = () => { const onSubmit: SubmitHandler = useCallback( async (data) => { - const result = await testAiCustomContentAction(data); - if (isActionError(result)) { + const result = await testAiCustomContentAction(email, data); + if (result?.serverError) { toastError({ title: "Error testing email", - description: result.error, + description: result.serverError, }); } else { - setTestResult(result); + setTestResult(result?.data); } }, - [], + [email], ); return ( diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index 8507befc0..623b369f5 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -38,10 +38,10 @@ import { type AddGroupItemBody, addGroupItemBody, } from "@/utils/actions/group.validation"; -import { isActionError } from "@/utils/error"; import { Badge } from "@/components/ui/badge"; import { formatShortDate } from "@/utils/date"; import { Tooltip } from "@/components/Tooltip"; +import { useAccount } from "@/providers/AccountProvider"; export function ViewGroup({ groupId }: { groupId: string }) { const { data, isLoading, error, mutate } = useSWR( @@ -127,6 +127,8 @@ const AddGroupItemForm = ({ mutate: KeyedMutator; setShowAddItem: Dispatch>; }) => { + const { email } = useAccount(); + const { register, handleSubmit, @@ -142,10 +144,10 @@ const AddGroupItemForm = ({ const onSubmit: SubmitHandler = useCallback( async (data) => { - const result = await addGroupItemAction(data); - if (isActionError(result)) { + const result = await addGroupItemAction(email, data); + if (result?.serverError) { toastError({ - description: `Failed to add pattern. ${result.error}`, + description: `Failed to add pattern. ${result.serverError || ""}`, }); } else { toastSuccess({ description: "Pattern added!" }); @@ -153,7 +155,7 @@ const AddGroupItemForm = ({ mutate(); onClose(); }, - [mutate, onClose], + [mutate, onClose, email], ); const handleKeyDown = useCallback( @@ -259,6 +261,8 @@ function GroupItemList({ items: GroupItem[]; mutate: KeyedMutator; }) { + const { email } = useAccount(); + return ( {title && ( @@ -302,10 +306,12 @@ function GroupItemList({ variant="outline" size="icon" onClick={async () => { - const result = await deleteGroupItemAction(item.id); - if (isActionError(result)) { + const result = await deleteGroupItemAction(email, { + id: item.id, + }); + if (result?.serverError) { toastError({ - description: `Failed to remove ${item.value}. ${result.error}`, + description: `Failed to remove ${item.value}. ${result.serverError || ""}`, }); } else { toastSuccess({ diff --git a/apps/web/app/(app)/automation/knowledge/KnowledgeBase.tsx b/apps/web/app/(app)/automation/knowledge/KnowledgeBase.tsx index 836891719..06538e4d4 100644 --- a/apps/web/app/(app)/automation/knowledge/KnowledgeBase.tsx +++ b/apps/web/app/(app)/automation/knowledge/KnowledgeBase.tsx @@ -22,15 +22,16 @@ import { } from "@/components/ui/dialog"; import { deleteKnowledgeAction } from "@/utils/actions/knowledge"; import { toastError, toastSuccess } from "@/components/Toast"; -import { isActionError } from "@/utils/error"; import { LoadingContent } from "@/components/LoadingContent"; import type { GetKnowledgeResponse } from "@/app/api/knowledge/route"; import { formatDateSimple } from "@/utils/date"; import type { Knowledge } from "@prisma/client"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { KnowledgeForm } from "@/app/(app)/automation/knowledge/KnowledgeForm"; +import { useAccount } from "@/providers/AccountProvider"; export function KnowledgeBase() { + const { email } = useAccount(); const [isOpen, setIsOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const { data, isLoading, error, mutate } = @@ -121,6 +122,7 @@ export function KnowledgeBase() { item={item} onEdit={() => setEditingItem(item)} onDelete={mutate} + email={email} /> )) )} @@ -136,10 +138,12 @@ function KnowledgeTableRow({ item, onEdit, onDelete, + email, }: { item: Knowledge; onEdit: () => void; onDelete: () => void; + email: string; }) { const [isDeleting, setIsDeleting] = useState(false); @@ -164,13 +168,13 @@ function KnowledgeTableRow({ onConfirm={async () => { try { setIsDeleting(true); - const result = await deleteKnowledgeAction({ + const result = await deleteKnowledgeAction(email, { id: item.id, }); - if (isActionError(result)) { + if (result?.serverError) { toastError({ title: "Error deleting knowledge base entry", - description: result.error, + description: result.serverError || "", }); return; } diff --git a/apps/web/app/(app)/automation/knowledge/KnowledgeForm.tsx b/apps/web/app/(app)/automation/knowledge/KnowledgeForm.tsx index e6df0d1b7..def8aa2a9 100644 --- a/apps/web/app/(app)/automation/knowledge/KnowledgeForm.tsx +++ b/apps/web/app/(app)/automation/knowledge/KnowledgeForm.tsx @@ -17,12 +17,12 @@ import { updateKnowledgeAction, } from "@/utils/actions/knowledge"; import { toastError, toastSuccess } from "@/components/Toast"; -import { isActionError } from "@/utils/error"; import type { GetKnowledgeResponse } from "@/app/api/knowledge/route"; import type { Knowledge } from "@prisma/client"; import { Tiptap, type TiptapHandle } from "@/components/editor/Tiptap"; import { Label } from "@/components/ui/label"; import { cn } from "@/utils"; +import { useAccount } from "@/providers/AccountProvider"; export function KnowledgeForm({ closeDialog, @@ -33,6 +33,8 @@ export function KnowledgeForm({ refetch: KeyedMutator; editingItem: Knowledge | null; }) { + const { email } = useAccount(); + const { register, handleSubmit, @@ -65,13 +67,13 @@ export function KnowledgeForm({ }; const result = editingItem - ? await updateKnowledgeAction(submitData as UpdateKnowledgeBody) - : await createKnowledgeAction(submitData); + ? await updateKnowledgeAction(email, submitData as UpdateKnowledgeBody) + : await createKnowledgeAction(email, submitData); - if (isActionError(result)) { + if (result?.serverError) { toastError({ title: `Error ${editingItem ? "updating" : "creating"} knowledge base entry`, - description: result.error, + description: result.serverError || "", }); return; } diff --git a/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx b/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx index a15fc5631..6b6712e2d 100644 --- a/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx +++ b/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx @@ -6,25 +6,25 @@ import { Card } from "@/components/ui/card"; import { TypographyH3, TypographyP } from "@/components/Typography"; import { ButtonListSurvey } from "@/components/ButtonListSurvey"; import { enableDraftRepliesAction } from "@/utils/actions/rule"; -import { isActionError } from "@/utils/error"; import { toastError } from "@/components/Toast"; import { ASSISTANT_ONBOARDING_COOKIE, markOnboardingAsCompleted, } from "@/utils/cookies"; +import { useAccount } from "@/providers/AccountProvider"; export default function DraftRepliesPage() { const router = useRouter(); - + const { email } = useAccount(); const onSetDraftReplies = useCallback( async (value: string) => { - const result = await enableDraftRepliesAction({ + const result = await enableDraftRepliesAction(email, { enable: value === "yes", }); - if (isActionError(result)) { + if (result?.serverError) { toastError({ - description: `There was an error: ${result.error}`, + description: `There was an error: ${result.serverError || ""}`, }); } @@ -32,7 +32,7 @@ export default function DraftRepliesPage() { router.push("/automation/onboarding/completed"); }, - [router], + [router, email], ); return ( diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx b/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx index 279519f43..aae104490 100644 --- a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx +++ b/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx @@ -7,8 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { deleteGroupItemAction } from "@/utils/actions/group"; import type { MessageWithGroupItem } from "@/app/(app)/automation/rule/[ruleId]/examples/types"; -import { isActionError } from "@/utils/error"; import { toastError } from "@/components/Toast"; +import { useAccount } from "@/providers/AccountProvider"; export function ExampleList({ groupedBySenders, @@ -16,6 +16,7 @@ export function ExampleList({ groupedBySenders: Dictionary; }) { const [removed, setRemoved] = useState([]); + const { email } = useAccount(); return (
@@ -49,11 +50,13 @@ export function ExampleList({ type="submit" size="sm" className="mt-4 text-wrap" - onClick={() => { - const result = deleteGroupItemAction(matchingGroupItem.id); - if (isActionError(result)) { + onClick={async () => { + const result = await deleteGroupItemAction(email, { + id: matchingGroupItem.id, + }); + if (result?.serverError) { toastError({ - description: `Failed to remove ${matchingGroupItem.value} from group. ${result.error}`, + description: `Failed to remove ${matchingGroupItem.value} from group. ${result.serverError || ""}`, }); } else { setRemoved([...removed, firstThreadId]); diff --git a/apps/web/app/(app)/clean/ConfirmationStep.tsx b/apps/web/app/(app)/clean/ConfirmationStep.tsx index bb7c3af86..2f32eec5a 100644 --- a/apps/web/app/(app)/clean/ConfirmationStep.tsx +++ b/apps/web/app/(app)/clean/ConfirmationStep.tsx @@ -7,11 +7,11 @@ import { TypographyH3 } from "@/components/Typography"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/Badge"; import { cleanInboxAction } from "@/utils/actions/clean"; -import { isActionError } from "@/utils/error"; import { toastError } from "@/components/Toast"; import { CleanAction } from "@prisma/client"; import { PREVIEW_RUN_COUNT } from "@/app/(app)/clean/consts"; import { HistoryIcon, SettingsIcon } from "lucide-react"; +import { useAccount } from "@/providers/AccountProvider"; export function ConfirmationStep({ showFooter, @@ -35,9 +35,10 @@ export function ConfirmationStep({ reuseSettings: boolean; }) { const router = useRouter(); + const { email } = useAccount(); const handleStartCleaning = async () => { - const result = await cleanInboxAction({ + const result = await cleanInboxAction(email, { daysOld: timeRange ?? 7, instructions: instructions || "", action: action || CleanAction.ARCHIVE, @@ -45,12 +46,12 @@ export function ConfirmationStep({ skips, }); - if (isActionError(result)) { - toastError({ description: result.error }); + if (result?.serverError) { + toastError({ description: result.serverError }); return; } - router.push(`/clean/run?jobId=${result.jobId}&isPreviewBatch=true`); + router.push(`/clean/run?jobId=${result?.data?.jobId}&isPreviewBatch=true`); }; return ( diff --git a/apps/web/app/(app)/clean/EmailFirehoseItem.tsx b/apps/web/app/(app)/clean/EmailFirehoseItem.tsx index 0b802aa41..092059e97 100644 --- a/apps/web/app/(app)/clean/EmailFirehoseItem.tsx +++ b/apps/web/app/(app)/clean/EmailFirehoseItem.tsx @@ -16,7 +16,6 @@ import { undoCleanInboxAction, changeKeepToDoneAction, } from "@/utils/actions/clean"; -import { isActionError } from "@/utils/error"; import { toastError } from "@/components/Toast"; import { getGmailUrl } from "@/utils/url"; import { CleanAction } from "@prisma/client"; @@ -77,6 +76,7 @@ export function EmailItem({ undoState={undoState} setUndoing={setUndoing} setUndone={setUndone} + userEmail={userEmail} />
@@ -103,6 +103,7 @@ function StatusBadge({ undoState, setUndoing, setUndone, + userEmail, }: { status: Status; email: CleanThread; @@ -110,6 +111,7 @@ function StatusBadge({ undoState?: "undoing" | "undone"; setUndoing: (threadId: string) => void; setUndone: (threadId: string) => void; + userEmail: string; }) { if (status === "processing") { return Processing...; @@ -151,14 +153,14 @@ function StatusBadge({ setUndoing(email.threadId); - const result = await undoCleanInboxAction({ + const result = await undoCleanInboxAction(userEmail, { threadId: email.threadId, markedDone: !!email.archive, action, }); - if (isActionError(result)) { - toastError({ description: result.error }); + if (result?.serverError) { + toastError({ description: result.serverError }); } else { setUndone(email.threadId); } @@ -187,13 +189,13 @@ function StatusBadge({ setUndoing(email.threadId); - const result = await changeKeepToDoneAction({ + const result = await changeKeepToDoneAction(userEmail, { threadId: email.threadId, action, }); - if (isActionError(result)) { - toastError({ description: result.error }); + if (result?.serverError) { + toastError({ description: result.serverError }); } else { setUndone(email.threadId); } diff --git a/apps/web/app/(app)/clean/PreviewBatch.tsx b/apps/web/app/(app)/clean/PreviewBatch.tsx index a2d2724fd..540f3e191 100644 --- a/apps/web/app/(app)/clean/PreviewBatch.tsx +++ b/apps/web/app/(app)/clean/PreviewBatch.tsx @@ -12,18 +12,18 @@ import { CardTitle, } from "@/components/ui/card"; import { cleanInboxAction } from "@/utils/actions/clean"; -import { isActionError } from "@/utils/error"; import { CleanAction, type CleanupJob } from "@prisma/client"; import { PREVIEW_RUN_COUNT } from "@/app/(app)/clean/consts"; - +import { useAccount } from "@/providers/AccountProvider"; export function PreviewBatch({ job }: { job: CleanupJob }) { + const { email } = useAccount(); const [, setIsPreviewBatch] = useQueryState("isPreviewBatch", parseAsBoolean); const [isLoading, setIsLoading] = useState(false); const handleRunOnFullInbox = async () => { setIsLoading(true); setIsPreviewBatch(false); - const result = await cleanInboxAction({ + const result = await cleanInboxAction(email, { daysOld: job.daysOld, instructions: job.instructions || "", action: job.action, @@ -39,8 +39,8 @@ export function PreviewBatch({ job }: { job: CleanupJob }) { setIsLoading(false); - if (isActionError(result)) { - toastError({ description: result.error }); + if (result?.serverError) { + toastError({ description: result.serverError }); return; } }; diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx index ada9e4bff..4af62648a 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx @@ -9,13 +9,14 @@ import { } from "@/utils/actions/cold-email.validation"; import { DEFAULT_COLD_EMAIL_PROMPT } from "@/utils/cold-email/prompt"; import { toastError, toastSuccess } from "@/components/Toast"; -import { isActionError } from "@/utils/error"; import { updateColdEmailPromptAction } from "@/utils/actions/cold-email"; - +import { useAccount } from "@/providers/AccountProvider"; export function ColdEmailPromptForm(props: { coldEmailPrompt?: string | null; onSuccess: () => void; }) { + const { email } = useAccount(); + const { register, handleSubmit, @@ -31,7 +32,7 @@ export function ColdEmailPromptForm(props: { const onSubmit: SubmitHandler = useCallback( async (data) => { - const result = await updateColdEmailPromptAction({ + const result = await updateColdEmailPromptAction(email, { // if user hasn't changed the prompt, unset their custom prompt coldEmailPrompt: !data.coldEmailPrompt || @@ -40,14 +41,14 @@ export function ColdEmailPromptForm(props: { : data.coldEmailPrompt, }); - if (isActionError(result)) { + if (result?.serverError) { toastError({ description: "Error updating cold email prompt." }); } else { toastSuccess({ description: "Prompt updated!" }); onSuccess(); } }, - [onSuccess], + [onSuccess, email], ); return ( diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx index c9cb97ea9..374c7e825 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx @@ -6,7 +6,6 @@ import { LoadingContent } from "@/components/LoadingContent"; import { toastError, toastSuccess } from "@/components/Toast"; import { zodResolver } from "@hookform/resolvers/zod"; import { ColdEmailSetting } from "@prisma/client"; -import { isActionError } from "@/utils/error"; import { Button } from "@/components/ui/button"; import { type UpdateColdEmailSettingsBody, @@ -16,6 +15,7 @@ import { updateColdEmailSettingsAction } from "@/utils/actions/cold-email"; import { ColdEmailPromptForm } from "@/app/(app)/cold-email-blocker/ColdEmailPromptForm"; import { RadioGroup } from "@/components/RadioGroup"; import { useUser } from "@/hooks/useUser"; +import { useAccount } from "@/providers/AccountProvider"; export function ColdEmailSettings() { const { data, isLoading, error, mutate } = useUser(); @@ -44,6 +44,8 @@ export function ColdEmailForm({ buttonText?: string; onSuccess?: () => void; }) { + const { email } = useAccount(); + const { control, handleSubmit, @@ -57,9 +59,9 @@ export function ColdEmailForm({ const onSubmit: SubmitHandler = useCallback( async (data) => { - const result = await updateColdEmailSettingsAction(data); + const result = await updateColdEmailSettingsAction(email, data); - if (isActionError(result)) { + if (result?.serverError) { toastError({ description: "There was an error updating the settings.", }); @@ -68,7 +70,7 @@ export function ColdEmailForm({ onSuccess?.(); } }, - [onSuccess], + [onSuccess, email], ); const onSubmitForm = handleSubmit(onSubmit); diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index dfca15a8a..3d209f996 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -116,7 +116,7 @@ export const testAiCustomContentAction = actionClient .schema(testAiCustomContentBody) .action( async ({ ctx: { email, emailAccount }, parsedInput: { content } }) => { - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); const gmail = await getGmailClientForEmail({ email }); @@ -175,7 +175,7 @@ export const createAutomationAction = actionClient if (!result) throw new SafeError("AI error creating rule."); const createdRule = await safeCreateRule({ result, email }); - return { ruleId: createdRule?.id }; + return createdRule; }); export const setRuleRunOnThreadsAction = actionClient diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index 11745794a..a6bc28570 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -26,6 +26,7 @@ import { getUnhandledCount } from "@/utils/assess"; import { hash } from "@/utils/hash"; import { getGmailClientForEmail } from "@/utils/account"; import { actionClient } from "@/utils/actions/safe-action"; +import { SafeError } from "@/utils/error"; const logger = createScopedLogger("actions/clean"); @@ -52,11 +53,11 @@ export const cleanInboxAction = actionClient const markedDoneLabelId = markedDoneLabel?.id; if (!markedDoneLabelId) - return { error: "Failed to create archived label" }; + throw new SafeError("Failed to create archived label"); const processedLabelId = processedLabel?.id; if (!processedLabelId) - return { error: "Failed to create processed label" }; + throw new SafeError("Failed to create processed label"); // create a cleanup job const job = await prisma.cleanupJob.create({ From 83c73119dbf5e26b1958a013055c634ef83636d5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 01:30:45 +0300 Subject: [PATCH 058/176] fix --- apps/web/app/(app)/clean/PreviewBatch.tsx | 1 + apps/web/utils/actions/categorize.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/clean/PreviewBatch.tsx b/apps/web/app/(app)/clean/PreviewBatch.tsx index 540f3e191..b3e4f6ecf 100644 --- a/apps/web/app/(app)/clean/PreviewBatch.tsx +++ b/apps/web/app/(app)/clean/PreviewBatch.tsx @@ -15,6 +15,7 @@ import { cleanInboxAction } from "@/utils/actions/clean"; import { CleanAction, type CleanupJob } from "@prisma/client"; import { PREVIEW_RUN_COUNT } from "@/app/(app)/clean/consts"; import { useAccount } from "@/providers/AccountProvider"; + export function PreviewBatch({ job }: { job: CleanupJob }) { const { email } = useAccount(); const [, setIsPreviewBatch] = useQueryState("isPreviewBatch", parseAsBoolean); diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 210368f79..228ee2d42 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -13,7 +13,7 @@ import { updateCategoryForSender, } from "@/utils/categorize/senders/categorize"; import { validateUserAndAiAccess } from "@/utils/user/validate"; -import { isActionError } from "@/utils/error"; +import { isActionError, SafeError } from "@/utils/error"; import { deleteEmptyCategorizeSendersQueues, publishToAiCategorizeSendersQueue, @@ -113,7 +113,7 @@ export const categorizeSenderAction = actionClient if (isActionError(userResult)) return userResult; const { emailAccount } = userResult; - if (!session.accessToken) return { error: "No access token" }; + if (!session.accessToken) throw new SafeError("No access token"); const result = await categorizeSender( senderAddress, From a4ff35f87ae3d8ab7d48737836563f40058b996b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 01:37:02 +0300 Subject: [PATCH 059/176] fix --- .../(app)/reply-zero/EnableReplyTracker.tsx | 5 ++--- .../(app)/reply-zero/ReplyTrackerEmails.tsx | 9 ++++----- .../(app)/settings/SignatureSectionForm.tsx | 5 ++--- apps/web/utils/actions/ai-rule.ts | 8 ++------ apps/web/utils/actions/clean.ts | 3 --- apps/web/utils/actions/mail.ts | 8 +++----- apps/web/utils/actions/reply-tracking.ts | 9 ++++----- apps/web/utils/actions/rule.ts | 20 +++++++++++-------- 8 files changed, 29 insertions(+), 38 deletions(-) diff --git a/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx b/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx index d79d30369..c71a37bde 100644 --- a/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx +++ b/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx @@ -11,7 +11,6 @@ import { enableReplyTrackerAction, processPreviousSentEmailsAction, } from "@/utils/actions/reply-tracking"; -import { isActionError } from "@/utils/error"; import { NEEDS_REPLY_LABEL_NAME, AWAITING_REPLY_LABEL_NAME, @@ -70,10 +69,10 @@ export function EnableReplyTracker({ enabled }: { enabled: boolean }) { const result = await enableReplyTrackerAction(email); - if (isActionError(result)) { + if (result?.serverError) { toastError({ title: "Error enabling Reply Zero", - description: result.error, + description: result.serverError, }); } else { toastSuccess({ diff --git a/apps/web/app/(app)/reply-zero/ReplyTrackerEmails.tsx b/apps/web/app/(app)/reply-zero/ReplyTrackerEmails.tsx index 3caf17e2e..d1e26188b 100644 --- a/apps/web/app/(app)/reply-zero/ReplyTrackerEmails.tsx +++ b/apps/web/app/(app)/reply-zero/ReplyTrackerEmails.tsx @@ -18,7 +18,6 @@ import { } from "lucide-react"; import { useThreadsByIds } from "@/hooks/useThreadsByIds"; import { resolveThreadTrackerAction } from "@/utils/actions/reply-tracking"; -import { isActionError } from "@/utils/error"; import { toastError, toastSuccess } from "@/components/Toast"; import { Loading } from "@/components/Loading"; import { TablePagination } from "@/components/TablePagination"; @@ -83,15 +82,15 @@ export function ReplyTrackerEmails({ return next; }); - const result = await resolveThreadTrackerAction({ + const result = await resolveThreadTrackerAction(email, { threadId, resolved, }); - if (isActionError(result)) { + if (result?.serverError) { toastError({ title: "Error", - description: result.error, + description: result.serverError, }); } else { toastSuccess({ @@ -110,7 +109,7 @@ export function ReplyTrackerEmails({ setSelectedEmail(null); } }, - [resolvingThreads, selectedEmail], + [resolvingThreads, selectedEmail, email], ); const handleAction = useCallback( diff --git a/apps/web/app/(app)/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/settings/SignatureSectionForm.tsx index a697c95f0..73212cbf5 100644 --- a/apps/web/app/(app)/settings/SignatureSectionForm.tsx +++ b/apps/web/app/(app)/settings/SignatureSectionForm.tsx @@ -16,7 +16,6 @@ import { SubmitButtonWrapper, } from "@/components/Form"; import { Tiptap, type TiptapHandle } from "@/components/editor/Tiptap"; -import { isActionError } from "@/utils/error"; import { toastError, toastInfo, toastSuccess } from "@/components/Toast"; import { ClientOnly } from "@/components/ClientOnly"; import { useAccount } from "@/providers/AccountProvider"; @@ -91,10 +90,10 @@ export const SignatureSectionForm = ({ onClick={async () => { const result = await executeLoadSignatureFromGmail(); - if (isActionError(result)) { + if (result?.serverError) { toastError({ title: "Error loading signature from Gmail", - description: result.error, + description: result.serverError, }); return; } else if (result?.data?.signature) { diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 3d209f996..7dda6912b 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -32,7 +32,6 @@ import { labelVisibility } from "@/utils/gmail/constants"; import type { CreateOrUpdateRuleSchemaWithCategories } from "@/utils/ai/rule/create-rule-schema"; import { deleteRule, safeCreateRule, safeUpdateRule } from "@/utils/rule/rule"; import { getUserCategoriesForNames } from "@/utils/category.server"; -import { getAiUser } from "@/utils/user/get"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; @@ -567,7 +566,7 @@ export const reportAiMistakeAction = actionClient if (!expectedRuleId && !actualRuleId) throw new SafeError("Either correct or incorrect rule ID is required"); - const [expectedRule, actualRule, user] = await Promise.all([ + const [expectedRule, actualRule] = await Promise.all([ expectedRuleId ? prisma.rule.findUnique({ where: { id: expectedRuleId, emailAccountId: email }, @@ -578,7 +577,6 @@ export const reportAiMistakeAction = actionClient where: { id: actualRuleId, emailAccountId: email }, }) : null, - getAiUser({ email }), ]); if (expectedRuleId && !expectedRule) @@ -587,8 +585,6 @@ export const reportAiMistakeAction = actionClient if (actualRuleId && !actualRule) throw new SafeError("Actual rule not found"); - if (!user) throw new SafeError("User not found"); - const content = emailToContent({ textHtml: message.textHtml || undefined, textPlain: message.textPlain || undefined, @@ -596,7 +592,7 @@ export const reportAiMistakeAction = actionClient }); const result = await aiRuleFix({ - user, + user: emailAccount, actualRule, expectedRule, email: { diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index a6bc28570..66b7840cb 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -80,9 +80,6 @@ export const cleanInboxAction = actionClient // let labels: { id: string; name: string }[] | undefined; - // const user = await getAiUser({ id: userId }); - // if (!user) throw new Error("User not found"); - // const labelNames = await aiCleanSelectLabels({ user, instructions }); // if (labelNames) { diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index c34d46803..c6eb70840 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -210,13 +210,11 @@ export const updateLabelsAction = actionClient export const sendEmailAction = actionClient .metadata({ name: "sendEmail" }) - .schema(z.object({ unsafeData: sendEmailBody })) - .action(async ({ ctx: { email }, parsedInput: { unsafeData } }) => { + .schema(sendEmailBody) + .action(async ({ ctx: { email }, parsedInput }) => { const gmail = await getGmailClientForEmail({ email }); - const body = sendEmailBody.parse(unsafeData); - - const result = await sendEmailWithHtml(gmail, body); + const result = await sendEmailWithHtml(gmail, parsedInput); return { success: true, diff --git a/apps/web/utils/actions/reply-tracking.ts b/apps/web/utils/actions/reply-tracking.ts index 065f30909..847970f94 100644 --- a/apps/web/utils/actions/reply-tracking.ts +++ b/apps/web/utils/actions/reply-tracking.ts @@ -10,9 +10,9 @@ import { stopAnalyzingReplyTracker, } from "@/utils/redis/reply-tracker-analyzing"; import { enableReplyTracker } from "@/utils/reply-tracker/enable"; -import { getAiUser } from "@/utils/user/get"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; +import { SafeError } from "@/utils/error"; const logger = createScopedLogger("enableReplyTracker"); @@ -28,12 +28,11 @@ export const enableReplyTrackerAction = actionClient export const processPreviousSentEmailsAction = actionClient .metadata({ name: "processPreviousSentEmails" }) - .action(async ({ ctx: { email } }) => { - const user = await getAiUser({ email }); - if (!user) return { error: "User not found" }; + .action(async ({ ctx: { email, emailAccount } }) => { + if (!emailAccount) throw new SafeError("Email account not found"); const gmail = await getGmailClientForEmail({ email }); - await processPreviousSentEmails(gmail, user); + await processPreviousSentEmails(gmail, emailAccount); return { success: true }; }); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 5d10fe3b7..abf54a42c 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -38,7 +38,6 @@ import { env } from "@/env"; import { INTERNAL_API_KEY_HEADER } from "@/utils/internal-api"; import type { ProcessPreviousBody } from "@/app/api/reply-tracker/process-previous/route"; import { RuleName } from "@/utils/rule/consts"; -import { getAiUser } from "@/utils/user/get"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; @@ -392,16 +391,21 @@ export const deleteRuleAction = actionClient export const getRuleExamplesAction = actionClient .metadata({ name: "getRuleExamples" }) .schema(rulesExamplesBody) - .action(async ({ ctx: { email }, parsedInput: { rulesPrompt } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action( + async ({ ctx: { email, emailAccount }, parsedInput: { rulesPrompt } }) => { + if (!emailAccount) throw new SafeError("Email account not found"); - const user = await getAiUser({ email }); - if (!user) return { error: "User not found" }; + const gmail = await getGmailClientForEmail({ email }); - const { matches } = await aiFindExampleMatches(user, gmail, rulesPrompt); + const { matches } = await aiFindExampleMatches( + emailAccount, + gmail, + rulesPrompt, + ); - return { matches }; - }); + return { matches }; + }, + ); export const createRulesOnboardingAction = actionClient .metadata({ name: "createRulesOnboarding" }) From a38b81337fa4de3fc5cd073594d04519cdfa1501 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 10:55:19 +0300 Subject: [PATCH 060/176] next safe action --- apps/web/app/(app)/automation/RulesPrompt.tsx | 41 +++++++++++-------- apps/web/app/(app)/automation/create/page.tsx | 22 +++------- apps/web/utils/actions/safe-action.ts | 32 ++++++++------- 3 files changed, 46 insertions(+), 49 deletions(-) diff --git a/apps/web/app/(app)/automation/RulesPrompt.tsx b/apps/web/app/(app)/automation/RulesPrompt.tsx index 39fe7cded..bb94568ac 100644 --- a/apps/web/app/(app)/automation/RulesPrompt.tsx +++ b/apps/web/app/(app)/automation/RulesPrompt.tsx @@ -13,7 +13,6 @@ import { saveRulesPromptAction, generateRulesPromptAction, } from "@/utils/actions/ai-rule"; -import { isActionError } from "@/utils/error"; import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Input } from "@/components/Input"; @@ -25,7 +24,6 @@ import { SectionHeader } from "@/components/Typography"; import type { RulesPromptResponse } from "@/app/api/user/rules/prompt/route"; import { LoadingContent } from "@/components/LoadingContent"; import { Tooltip } from "@/components/Tooltip"; -import { handleActionCall } from "@/utils/server-action"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; import { AutomationOnboarding } from "@/app/(app)/automation/AutomationOnboarding"; import { examplePrompts, personas } from "@/app/(app)/automation/examples"; @@ -33,6 +31,7 @@ import { PersonaDialog } from "@/app/(app)/automation/PersonaDialog"; import { useModal } from "@/hooks/useModal"; import { ProcessingPromptFileDialog } from "@/app/(app)/automation/ProcessingPromptFileDialog"; import { AlertBasic } from "@/components/Alert"; +import { useAccount } from "@/providers/AccountProvider"; export function RulesPrompt() { const { data, isLoading, error, mutate } = useSWR< @@ -86,6 +85,7 @@ function RulesPromptForm({ mutate: () => void; onOpenPersonaDialog: () => void; }) { + const { email } = useAccount(); const [isSubmitting, setIsSubmitting] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -135,13 +135,11 @@ function RulesPromptForm({ const saveRulesPromise = async (data: SaveRulesPromptBody) => { setIsSubmitting(true); - const result = await handleActionCall("saveRulesPromptAction", () => - saveRulesPromptAction(data), - ); + const result = await saveRulesPromptAction(email, data); - if (isActionError(result)) { + if (result?.serverError) { setIsSubmitting(false); - throw new Error(result.error); + throw new Error(result.serverError); } if (viewedProcessingPromptFileDialog) { @@ -162,8 +160,12 @@ function RulesPromptForm({ toast.promise(() => saveRulesPromise(data), { loading: "Saving rules... This may take a while to process...", success: (result) => { - setResult(result); - const { createdRules, editedRules, removedRules } = result || {}; + const { + createdRules = 0, + editedRules = 0, + removedRules = 0, + } = result?.data || {}; + setResult({ createdRules, editedRules, removedRules }); const message = [ createdRules ? `${createdRules} rules created.` : "", @@ -180,7 +182,7 @@ function RulesPromptForm({ }, }); }, - [mutate, router, viewedProcessingPromptFileDialog], + [mutate, router, viewedProcessingPromptFileDialog, email], ); const addExamplePrompt = useCallback( @@ -272,21 +274,24 @@ Let me know if you're interested! toast.promise( async () => { setIsGenerating(true); - const result = await handleActionCall( - "generateRulesPromptAction", - generateRulesPromptAction, + const result = await generateRulesPromptAction( + email, + {}, ); - if (isActionError(result)) { + if (result?.serverError) { setIsGenerating(false); - throw new Error(result.error); + throw new Error(result.serverError); } const currentPrompt = getValues("rulesPrompt"); const updatedPrompt = currentPrompt - ? `${currentPrompt}\n\n${result.rulesPrompt}` - : result.rulesPrompt; - setValue("rulesPrompt", updatedPrompt.trim()); + ? `${currentPrompt}\n\n${result?.data?.rulesPrompt}` + : result?.data?.rulesPrompt; + setValue( + "rulesPrompt", + updatedPrompt?.trim() || "", + ); setIsGenerating(false); diff --git a/apps/web/app/(app)/automation/create/page.tsx b/apps/web/app/(app)/automation/create/page.tsx index c8b9844fd..177894f74 100644 --- a/apps/web/app/(app)/automation/create/page.tsx +++ b/apps/web/app/(app)/automation/create/page.tsx @@ -14,8 +14,7 @@ import { } from "@/components/Typography"; import { Button } from "@/components/ui/button"; import { createAutomationAction } from "@/utils/actions/ai-rule"; -import { isActionError } from "@/utils/error"; -import { toastError, toastInfo } from "@/components/Toast"; +import { toastError } from "@/components/Toast"; import { examples } from "@/app/(app)/automation/create/examples"; import { useAccount } from "@/providers/AccountProvider"; import type { CreateAutomationBody } from "@/utils/actions/ai-rule.validation"; @@ -40,25 +39,16 @@ export default function AutomationSettingsPage() { prompt: data.prompt, }); - if (isActionError(result)) { - const existingRuleId = result.existingRuleId; - if (existingRuleId) { - toastInfo({ - title: "Rule for group already exists", - description: "Edit the existing rule to create your automation.", - }); - router.push(`/automation/rule/${existingRuleId}`); - } else { - toastError({ - description: `There was an error creating your automation. ${result.error}`, - }); - } + if (result?.serverError) { + toastError({ + description: `There was an error creating your automation. ${result.serverError || ""}`, + }); } else if (!result) { toastError({ description: "There was an error creating your automation.", }); } else { - router.push(`/automation/rule/${result.data?.ruleId}?new=true`); + router.push(`/automation/rule/${result.data?.id}?new=true`); } } }, diff --git a/apps/web/utils/actions/safe-action.ts b/apps/web/utils/actions/safe-action.ts index c21089c79..5fec40f57 100644 --- a/apps/web/utils/actions/safe-action.ts +++ b/apps/web/utils/actions/safe-action.ts @@ -1,7 +1,4 @@ -import { - createSafeActionClient, - DEFAULT_SERVER_ERROR_MESSAGE, -} from "next-safe-action"; +import { createSafeActionClient } from "next-safe-action"; import { withServerActionInstrumentation } from "@sentry/nextjs"; import { z } from "zod"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; @@ -18,24 +15,28 @@ const baseClient = createSafeActionClient({ defineMetadataSchema() { return z.object({ name: z.string() }); }, - handleServerError(e) { - if (e instanceof SafeError) return e.message; - - return DEFAULT_SERVER_ERROR_MESSAGE; + handleServerError(error) { + logger.error("Server action error:", { error }); + if (error instanceof SafeError) return error.message; + return "An unknown error occurred."; }, }).use(async ({ next, metadata }) => { logger.info("Calling action", { name: metadata?.name }); return next(); }); +// .schema(z.object({}), { +// handleValidationErrorsShape: async (ve) => +// flattenValidationErrors(ve).fieldErrors, +// }); export const actionClient = baseClient .bindArgsSchemas<[activeEmail: z.ZodString]>([z.string()]) .use(async ({ next, metadata, bindArgsClientInputs }) => { const session = await auth(); - if (!session?.user) throw new Error("Unauthorized"); + if (!session?.user) throw new SafeError("Unauthorized"); const userEmail = session.user.email; - if (!userEmail) throw new Error("Unauthorized"); + if (!userEmail) throw new SafeError("Unauthorized"); const userId = session.user.id; const email = bindArgsClientInputs[0] as string; @@ -47,7 +48,7 @@ export const actionClient = baseClient }) : null; if (email && emailAccount?.userId !== userId) - throw new Error("Unauthorized"); + throw new SafeError("Unauthorized"); return withServerActionInstrumentation(metadata?.name, async () => { return next({ @@ -66,9 +67,9 @@ export const actionClient = baseClient export const actionClientUser = baseClient.use(async ({ next, metadata }) => { const session = await auth(); - if (!session?.user) throw new Error("Unauthorized"); + if (!session?.user) throw new SafeError("Unauthorized"); const userEmail = session.user.email; - if (!userEmail) throw new Error("Unauthorized"); + if (!userEmail) throw new SafeError("Unauthorized"); const userId = session.user.id; @@ -81,8 +82,9 @@ export const actionClientUser = baseClient.use(async ({ next, metadata }) => { export const adminActionClient = baseClient.use(async ({ next, metadata }) => { const session = await auth(); - if (!session?.user) throw new Error("Unauthorized"); - if (!isAdmin({ email: session.user.email })) throw new Error("Unauthorized"); + if (!session?.user) throw new SafeError("Unauthorized"); + if (!isAdmin({ email: session.user.email })) + throw new SafeError("Unauthorized"); return withServerActionInstrumentation(metadata?.name, async () => { return next({ ctx: {} }); From 109de9f92c2ab73cdf47f61af0913107914cc95d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 11:16:40 +0300 Subject: [PATCH 061/176] remove isactionerror --- .cursor/rules/server-actions.mdc | 9 +----- .../(app)/cold-email-blocker/TestRules.tsx | 17 +++++++---- .../app/(app)/compose/ComposeEmailForm.tsx | 13 +++++---- .../onboarding/OnboardingEmailAssistant.tsx | 18 +++++++----- apps/web/app/(app)/premium/Pricing.tsx | 1 - .../CategorizeWithAiButton.tsx | 17 +++++------ .../(app)/smart-categories/Uncategorized.tsx | 5 +++- .../categorize/senders/batch/handle-batch.ts | 12 +++----- apps/web/store/ai-categorize-sender-queue.ts | 29 ++++++++++++++----- apps/web/utils/actions/ai-rule.ts | 6 ++-- apps/web/utils/actions/assess.ts | 3 +- apps/web/utils/actions/categorize.ts | 6 ++-- apps/web/utils/actions/cold-email.ts | 3 +- apps/web/utils/actions/premium.ts | 16 +++++----- apps/web/utils/actions/rule.ts | 8 ++--- .../utils/categorize/senders/categorize.ts | 3 +- apps/web/utils/error.ts | 6 ++-- apps/web/utils/reply-tracker/enable.ts | 13 +++++---- apps/web/utils/user/validate.ts | 5 ++-- 19 files changed, 102 insertions(+), 88 deletions(-) diff --git a/.cursor/rules/server-actions.mdc b/.cursor/rules/server-actions.mdc index 4318e9369..a2c674bd9 100644 --- a/.cursor/rules/server-actions.mdc +++ b/.cursor/rules/server-actions.mdc @@ -38,17 +38,10 @@ import { type DeactivateApiKeyBody, } from "@/utils/actions/api-key.validation"; +// TODO: use next-safe-action export const deactivateApiKeyAction = withActionInstrumentation( "deactivateApiKey", async (unsafeData: DeactivateApiKeyBody) => { - const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; - - const { data, success, error } = - deactivateApiKeyBody.safeParse(unsafeData); - if (!success) return { error: error.message }; - await prisma.apiKey.update({ where: { id: data.id, userId }, data: { isActive: false }, diff --git a/apps/web/app/(app)/cold-email-blocker/TestRules.tsx b/apps/web/app/(app)/cold-email-blocker/TestRules.tsx index b516c3030..f9385ed32 100644 --- a/apps/web/app/(app)/cold-email-blocker/TestRules.tsx +++ b/apps/web/app/(app)/cold-email-blocker/TestRules.tsx @@ -10,7 +10,6 @@ import { SparklesIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; import { toastError } from "@/components/Toast"; -import { isActionError } from "@/utils/error"; import { LoadingContent } from "@/components/LoadingContent"; import type { MessagesResponse } from "@/app/api/google/messages/route"; import { Separator } from "@/components/ui/separator"; @@ -23,6 +22,12 @@ import { testColdEmailAction } from "@/utils/actions/cold-email"; import type { ColdEmailBlockerBody } from "@/utils/actions/cold-email.validation"; import { useAccount } from "@/providers/AccountProvider"; +type ColdEmailBlockerResponse = { + isColdEmail: boolean; + aiReason?: string | null; + reason?: string | null; +}; + export function TestRulesContent() { const [searchQuery, setSearchQuery] = useState(""); const { data, isLoading, error } = useSWR( @@ -221,14 +226,14 @@ function useColdEmailTest() { const testEmail = async (data: ColdEmailBlockerBody) => { setTesting(true); try { - const res = await testColdEmailAction(userEmail, data); - if (isActionError(res)) { + const result = await testColdEmailAction(userEmail, data); + if (result?.serverError) { toastError({ title: "Error checking whether it's a cold email.", - description: res.error, + description: result.serverError, }); - } else { - setResponse(res); + } else if (result?.data) { + setResponse(result.data); } } finally { setTesting(false); diff --git a/apps/web/app/(app)/compose/ComposeEmailForm.tsx b/apps/web/app/(app)/compose/ComposeEmailForm.tsx index dba3306a3..307a0726d 100644 --- a/apps/web/app/(app)/compose/ComposeEmailForm.tsx +++ b/apps/web/app/(app)/compose/ComposeEmailForm.tsx @@ -20,13 +20,13 @@ import { Button } from "@/components/ui/button"; import { ButtonLoader } from "@/components/Loading"; import { env } from "@/env"; import { extractNameFromEmail } from "@/utils/email"; -import { isActionError } from "@/utils/error"; import { Tiptap, type TiptapHandle } from "@/components/editor/Tiptap"; import { sendEmailAction } from "@/utils/actions/mail"; import type { ContactsResponse } from "@/app/api/google/contacts/route"; import type { SendEmailBody } from "@/utils/gmail/mail"; import { CommandShortcut } from "@/components/ui/command"; import { useModifierKey } from "@/hooks/useModifierKey"; +import { useAccount } from "@/providers/AccountProvider"; export type ReplyingToEmail = { threadId: string; @@ -52,6 +52,7 @@ export const ComposeEmailForm = ({ onSuccess?: (messageId: string, threadId: string) => void; onDiscard?: () => void; }) => { + const { email } = useAccount(); const [showFullContent, setShowFullContent] = React.useState(false); const { symbol } = useModifierKey(); const formRef = useRef(null); @@ -82,14 +83,14 @@ export const ComposeEmailForm = ({ }; try { - const res = await sendEmailAction(enrichedData); - if (isActionError(res)) { + const res = await sendEmailAction(email, enrichedData); + if (res?.serverError) { toastError({ description: "There was an error sending the email :(", }); - } else { + } else if (res?.data) { toastSuccess({ description: "Email sent!" }); - onSuccess?.(res.messageId ?? "", res.threadId ?? ""); + onSuccess?.(res.data.messageId ?? "", res.data.threadId ?? ""); } } catch (error) { console.error(error); @@ -98,7 +99,7 @@ export const ComposeEmailForm = ({ refetch?.(); }, - [refetch, onSuccess, showFullContent, replyingToEmail], + [refetch, onSuccess, showFullContent, replyingToEmail, email], ); useHotkeys( diff --git a/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx b/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx index 433ebbaa7..eea564e7d 100644 --- a/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx @@ -5,6 +5,7 @@ import { useForm } from "react-hook-form"; import Link from "next/link"; import type { SubmitHandler } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import type { InferSafeActionFnResult } from "next-safe-action"; import { Input } from "@/components/Input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/Badge"; @@ -23,15 +24,17 @@ import { OnboardingNextButton } from "@/app/(app)/onboarding/OnboardingNextButto import { decodeSnippet } from "@/utils/gmail/decode"; import { Loading } from "@/components/Loading"; import { getRuleExamplesAction } from "@/utils/actions/rule"; -import { isActionError } from "@/utils/error"; import { toastError } from "@/components/Toast"; import { rulesExamplesBody, type RulesExamplesBody, } from "@/utils/actions/rule.validation"; import { examplePrompts } from "@/app/(app)/automation/examples"; +import { useAccount } from "@/providers/AccountProvider"; -type RulesExamplesResponse = Awaited>; +type RulesExamplesResponse = InferSafeActionFnResult< + typeof getRuleExamplesAction +>["data"]; export function OnboardingAIEmailAssistant({ step }: { step: number }) { const [showNextButton, setShowNextButton] = useState(false); @@ -55,6 +58,7 @@ function EmailAssistantForm({ setShowNextButton: (show: boolean) => void; step: number; }) { + const { email } = useAccount(); const [data, setData] = useState(); const { @@ -71,19 +75,19 @@ function EmailAssistantForm({ }); const onSubmit: SubmitHandler = async (data) => { - const result = await getRuleExamplesAction(data); + const result = await getRuleExamplesAction(email, data); setShowNextButton(true); - if (isActionError(result)) { + if (result?.serverError) { toastError({ title: "Error getting rule examples", - description: result.error, + description: result.serverError, }); return; } - if (result.success) { - setData(result); + if (result?.data) { + setData(result.data); } }; diff --git a/apps/web/app/(app)/premium/Pricing.tsx b/apps/web/app/(app)/premium/Pricing.tsx index 7051519e2..5cee9a48c 100644 --- a/apps/web/app/(app)/premium/Pricing.tsx +++ b/apps/web/app/(app)/premium/Pricing.tsx @@ -23,7 +23,6 @@ import { } from "@/app/(app)/premium/config"; import { AlertWithButton } from "@/components/Alert"; import { switchPremiumPlanAction } from "@/utils/actions/premium"; -import { isActionError } from "@/utils/error"; import { TooltipExplanation } from "@/components/TooltipExplanation"; import { PremiumTier } from "@prisma/client"; import { diff --git a/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx index 61e7a443b..0ab64bce2 100644 --- a/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx @@ -5,19 +5,19 @@ import { SparklesIcon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { bulkCategorizeSendersAction } from "@/utils/actions/categorize"; -import { handleActionCall } from "@/utils/server-action"; -import { isActionError } from "@/utils/error"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; import type { ButtonProps } from "@/components/ui/button"; import { useCategorizeProgress } from "@/app/(app)/smart-categories/CategorizeProgress"; import { Tooltip } from "@/components/Tooltip"; +import { useAccount } from "@/providers/AccountProvider"; export function CategorizeWithAiButton({ buttonProps, }: { buttonProps?: ButtonProps; }) { + const { email } = useAccount(); const [isCategorizing, setIsCategorizing] = useState(false); const { hasAiAccess } = usePremium(); const { PremiumModal, openModal: openPremiumModal } = usePremiumModal(); @@ -40,23 +40,20 @@ export function CategorizeWithAiButton({ async () => { setIsCategorizing(true); setIsBulkCategorizing(true); - const result = await handleActionCall( - "bulkCategorizeSendersAction", - bulkCategorizeSendersAction, - ); + const result = await bulkCategorizeSendersAction(email); - if (isActionError(result)) { + if (result?.serverError) { setIsCategorizing(false); - throw new Error(result.error); + throw new Error(result.serverError); } setIsCategorizing(false); - return result; + return result?.data?.totalUncategorizedSenders || 0; }, { loading: "Categorizing senders... This might take a while.", - success: ({ totalUncategorizedSenders }) => { + success: (totalUncategorizedSenders) => { return totalUncategorizedSenders ? `Categorizing ${totalUncategorizedSenders} senders...` : "There are no more senders to categorize."; diff --git a/apps/web/app/(app)/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/smart-categories/Uncategorized.tsx index b6388e9bf..ca50a9efd 100644 --- a/apps/web/app/(app)/smart-categories/Uncategorized.tsx +++ b/apps/web/app/(app)/smart-categories/Uncategorized.tsx @@ -65,7 +65,10 @@ export function Uncategorized({ return; } - pushToAiCategorizeSenderQueueAtom(senderAddresses); + pushToAiCategorizeSenderQueueAtom({ + pushIds: senderAddresses, + email: userEmail, + }); }} > diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts index e07ffa7a0..c8f78c475 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts @@ -6,13 +6,13 @@ import { getCategories, updateSenderCategory, } from "@/utils/categorize/senders/categorize"; -import { isActionError } from "@/utils/error"; import { validateUserAndAiAccess } from "@/utils/user/validate"; import { getGmailClientWithRefresh } from "@/utils/gmail/client"; import { UNKNOWN_CATEGORY } from "@/utils/ai/categorize-sender/ai-categorize-senders"; import { createScopedLogger } from "@/utils/logger"; import prisma from "@/utils/prisma"; import { saveCategorizationProgress } from "@/utils/redis/categorization-progress"; +import { SafeError } from "@/utils/error"; const logger = createScopedLogger("api/user/categorize/senders/batch"); @@ -20,9 +20,7 @@ export async function handleBatchRequest( request: Request, ): Promise { try { - const handleBatchResult = await handleBatchInternal(request); - if (isActionError(handleBatchResult)) - return NextResponse.json({ error: handleBatchResult.error }); + await handleBatchInternal(request); return NextResponse.json({ ok: true }); } catch (error) { logger.error("Handle batch request error", { error }); @@ -41,11 +39,9 @@ async function handleBatchInternal(request: Request) { logger.trace("Handle batch request", { email, senders: senders.length }); const userResult = await validateUserAndAiAccess({ email }); - if (isActionError(userResult)) return userResult; const { emailAccount } = userResult; const categoriesResult = await getCategories({ email }); - if (isActionError(categoriesResult)) return categoriesResult; const { categories } = categoriesResult; const emailAccountWithAccount = await prisma.emailAccount.findUnique({ @@ -64,9 +60,9 @@ async function handleBatchInternal(request: Request) { const account = emailAccountWithAccount?.account; - if (!account) return { error: "No account found" }; + if (!account) throw new SafeError("No account found"); if (!account.access_token || !account.refresh_token) - return { error: "No access or refresh token" }; + throw new SafeError("No access or refresh token"); const gmail = await getGmailClientWithRefresh( { diff --git a/apps/web/store/ai-categorize-sender-queue.ts b/apps/web/store/ai-categorize-sender-queue.ts index 42296a272..8c505dddf 100644 --- a/apps/web/store/ai-categorize-sender-queue.ts +++ b/apps/web/store/ai-categorize-sender-queue.ts @@ -4,7 +4,6 @@ import pRetry from "p-retry"; import { jotaiStore } from "@/store"; import { exponentialBackoff } from "@/utils/sleep"; import { sleep } from "@/utils/sleep"; -import { isActionError } from "@/utils/error"; import { categorizeSenderAction } from "@/utils/actions/categorize"; import { aiQueue } from "@/utils/queue/ai-queue"; @@ -17,7 +16,13 @@ interface QueueItem { const aiCategorizeSenderQueueAtom = atom>(new Map()); -export const pushToAiCategorizeSenderQueueAtom = (pushIds: string[]) => { +export const pushToAiCategorizeSenderQueueAtom = ({ + pushIds, + email, +}: { + pushIds: string[]; + email: string; +}) => { jotaiStore.set(aiCategorizeSenderQueueAtom, (prev) => { const newQueue = new Map(prev); for (const id of pushIds) { @@ -28,7 +33,7 @@ export const pushToAiCategorizeSenderQueueAtom = (pushIds: string[]) => { return newQueue; }); - processAiCategorizeSenderQueue({ senders: pushIds }); + processAiCategorizeSenderQueue({ senders: pushIds, email }); }; export const stopAiCategorizeSenderQueue = () => { @@ -57,7 +62,13 @@ export const useHasProcessingItems = () => { return useAtomValue(hasProcessingItemsAtom); }; -function processAiCategorizeSenderQueue({ senders }: { senders: string[] }) { +function processAiCategorizeSenderQueue({ + senders, + email, +}: { + senders: string[]; + email: string; +}) { const tasks = senders.map((sender) => async () => { jotaiStore.set(aiCategorizeSenderQueueAtom, (prev) => { const newQueue = new Map(prev); @@ -71,18 +82,20 @@ function processAiCategorizeSenderQueue({ senders }: { senders: string[] }) { `Queue: aiCategorizeSender. Processing ${sender}${attemptCount > 1 ? ` (attempt ${attemptCount})` : ""}`, ); - const result = await categorizeSenderAction(sender); + const result = await categorizeSenderAction(email, { + senderAddress: sender, + }); - if (isActionError(result)) { + if (result?.serverError) { await sleep(exponentialBackoff(attemptCount, 1_000)); - throw new Error(result.error); + throw new Error(result.serverError); } jotaiStore.set(aiCategorizeSenderQueueAtom, (prev) => { const newQueue = new Map(prev); newQueue.set(sender, { status: "completed", - categoryId: result.categoryId || undefined, + categoryId: result?.data?.categoryId || undefined, }); return newQueue; }); diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 7dda6912b..b19daa7cd 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -12,7 +12,7 @@ import { emailToContent, parseMessage } from "@/utils/mail"; import { getMessage, getMessages } from "@/utils/gmail/message"; import { executeAct } from "@/utils/ai/choose-rule/execute"; import { isDefined } from "@/utils/types"; -import { isActionError, SafeError } from "@/utils/error"; +import { SafeError } from "@/utils/error"; import { createAutomationBody, reportAiMistakeBody, @@ -481,7 +481,7 @@ export const generateRulesPromptAction = actionClient .metadata({ name: "generateRulesPrompt" }) .schema(z.object({})) .action(async ({ ctx: { email, emailAccount } }) => { - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); const gmail = await getGmailClientForEmail({ email }); const lastSent = await getMessages(gmail, { @@ -537,7 +537,6 @@ export const generateRulesPromptAction = actionClient userLabels: labelsWithCounts.map((label) => label.label), }); - if (isActionError(result)) throw new SafeError(result.error); if (!result) throw new SafeError("Error generating rules prompt"); return { rulesPrompt: result.join("\n\n") }; @@ -603,7 +602,6 @@ export const reportAiMistakeAction = actionClient explanation: explanation?.trim() || undefined, }); - if (isActionError(result)) throw new SafeError(result.error); if (!result) throw new SafeError("Error fixing rule"); return { diff --git a/apps/web/utils/actions/assess.ts b/apps/web/utils/actions/assess.ts index b80055ea8..4b785815e 100644 --- a/apps/web/utils/actions/assess.ts +++ b/apps/web/utils/actions/assess.ts @@ -8,6 +8,7 @@ import { getSentMessages } from "@/utils/gmail/message"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; +import { SafeError } from "@/utils/error"; // to help with onboarding and provide the best flow to new users export const assessAction = actionClient @@ -34,7 +35,7 @@ export const assessAction = actionClient export const analyzeWritingStyleAction = actionClient .metadata({ name: "analyzeWritingStyle" }) .action(async ({ ctx: { email, emailAccount } }) => { - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); if (emailAccount?.writingStyle) return { success: true, skipped: true }; diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 228ee2d42..4ac61e39a 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -13,7 +13,7 @@ import { updateCategoryForSender, } from "@/utils/categorize/senders/categorize"; import { validateUserAndAiAccess } from "@/utils/user/validate"; -import { isActionError, SafeError } from "@/utils/error"; +import { SafeError } from "@/utils/error"; import { deleteEmptyCategorizeSendersQueues, publishToAiCategorizeSendersQueue, @@ -30,8 +30,7 @@ const logger = createScopedLogger("actions/categorize"); export const bulkCategorizeSendersAction = actionClient .metadata({ name: "bulkCategorizeSenders" }) .action(async ({ ctx: { email } }) => { - const userResult = await validateUserAndAiAccess({ email }); - if (isActionError(userResult)) return userResult; + await validateUserAndAiAccess({ email }); // Delete empty queues as Qstash has a limit on how many queues we can have // We could run this in a cron too but simplest to do here for now @@ -110,7 +109,6 @@ export const categorizeSenderAction = actionClient const gmail = await getGmailClientForEmail({ email }); const userResult = await validateUserAndAiAccess({ email }); - if (isActionError(userResult)) return userResult; const { emailAccount } = userResult; if (!session.accessToken) throw new SafeError("No access token"); diff --git a/apps/web/utils/actions/cold-email.ts b/apps/web/utils/actions/cold-email.ts index 0f1d04a79..d23766024 100644 --- a/apps/web/utils/actions/cold-email.ts +++ b/apps/web/utils/actions/cold-email.ts @@ -17,6 +17,7 @@ import { } from "@/utils/actions/cold-email.validation"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; +import { SafeError } from "@/utils/error"; export const updateColdEmailSettingsAction = actionClient .metadata({ name: "updateColdEmailSettings" }) @@ -104,7 +105,7 @@ export const testColdEmailAction = actionClient date, }, }) => { - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); const gmail = await getGmailClientForEmail({ email }); diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index dfc382ab7..3658c2f89 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -21,6 +21,7 @@ import { adminActionClient, } from "@/utils/actions/safe-action"; import { activateLicenseKeySchema } from "@/utils/actions/premium.validation"; +import { SafeError } from "@/utils/error"; export const decrementUnsubscribeCreditAction = actionClientUser .metadata({ name: "decrementUnsubscribeCredit" }) @@ -260,7 +261,7 @@ export const changePremiumStatusAction = adminActionClient select: { id: true, premiumId: true }, }); - if (!userToUpgrade) return { error: "User not found" }; + if (!userToUpgrade) throw new SafeError("User not found"); let lemonSqueezySubscriptionId: number | null = null; let lemonSqueezySubscriptionItemId: number | null = null; @@ -273,11 +274,12 @@ export const changePremiumStatusAction = adminActionClient const lemonCustomer = await getLemonCustomer( lemonSqueezyCustomerId.toString(), ); - if (!lemonCustomer.data) return { error: "Lemon customer not found" }; + if (!lemonCustomer.data) + throw new SafeError("Lemon customer not found"); const subscription = lemonCustomer.data.included?.find( (i) => i.type === "subscriptions", ); - if (!subscription) return { error: "Subscription not found" }; + if (!subscription) throw new SafeError("Subscription not found"); lemonSqueezySubscriptionId = Number.parseInt(subscription.id); const attributes = subscription.attributes as any; lemonSqueezyOrderId = Number.parseInt(attributes.order_id); @@ -325,7 +327,7 @@ export const changePremiumStatusAction = adminActionClient expired: true, }); } else { - return { error: "User not premium." }; + throw new SafeError("User not premium."); } } }, @@ -339,9 +341,9 @@ export const claimPremiumAdminAction = actionClientUser select: { premium: { select: { id: true, admins: true } } }, }); - if (!user) return { error: "User not found" }; - if (!user.premium?.id) return { error: "User does not have a premium" }; - if (user.premium?.admins.length) return { error: "Already has admin" }; + if (!user) throw new SafeError("User not found"); + if (!user.premium?.id) throw new SafeError("User does not have a premium"); + if (user.premium?.admins.length) throw new SafeError("Already has admin"); await prisma.premium.update({ where: { id: user.premium.id }, diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index abf54a42c..061406a0f 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -114,12 +114,12 @@ export const createRuleAction = actionClient return { rule }; } catch (error) { if (isDuplicateError(error, "name")) { - return { error: "Rule name already exists" }; + throw new SafeError("Rule name already exists"); } if (isDuplicateError(error, "groupId")) { - return { - error: "Group already has a rule. Please use the existing rule.", - }; + throw new SafeError( + "Group already has a rule. Please use the existing rule.", + ); } logger.error("Error creating rule", { error }); diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index 35522094a..4512a7412 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -11,6 +11,7 @@ import { getUserCategories } from "@/utils/category.server"; import type { UserEmailWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import { extractEmailAddress } from "@/utils/email"; +import { SafeError } from "@/utils/error"; const logger = createScopedLogger("categorize/senders"); @@ -142,7 +143,7 @@ function preCategorizeSendersWithStaticRules( export async function getCategories({ email }: { email: string }) { const categories = await getUserCategories({ email }); - if (categories.length === 0) return { error: "No categories found" }; + if (categories.length === 0) throw new SafeError("No categories found"); return { categories }; } diff --git a/apps/web/utils/error.ts b/apps/web/utils/error.ts index 3ac208c15..f9ae1774a 100644 --- a/apps/web/utils/error.ts +++ b/apps/web/utils/error.ts @@ -79,9 +79,9 @@ export type ServerActionResponse< E extends object = Record, > = ActionError | T; -export function isActionError(error: any): error is ActionError { - return error && typeof error === "object" && "error" in error && error.error; -} +// export function isActionError(error: any): error is ActionError { +// return error && typeof error === "object" && "error" in error && error.error; +// } // This class is used to throw error messages that are safe to expose to the client. export class SafeError extends Error { diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index 01223152b..001ef2a63 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -7,6 +7,7 @@ import { } from "@/utils/reply-tracker/consts"; import { createScopedLogger } from "@/utils/logger"; import { RuleName } from "@/utils/rule/consts"; +import { SafeError } from "@/utils/error"; export async function enableReplyTracker({ email }: { email: string }) { const logger = createScopedLogger("reply-tracker/enable").with({ email }); @@ -42,7 +43,7 @@ export async function enableReplyTracker({ email }: { email: string }) { }); // If enabled already skip - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); const rule = emailAccount.rules.find( (r) => r.systemType === SystemType.TO_REPLY, @@ -101,16 +102,16 @@ export async function enableReplyTracker({ email }: { email: string }) { systemType: SystemType.TO_REPLY, }); - if ("error" in newRule) { + if (newRule && "error" in newRule) { logger.error("Error enabling Reply Zero", { error: newRule.error }); - return { error: "Error enabling Reply Zero" }; + throw new SafeError("Error enabling Reply Zero"); } - ruleId = newRule.id; + ruleId = newRule?.id || null; if (!ruleId) { logger.error("Error enabling Reply Zero, no rule found"); - return { error: "Error enabling Reply Zero" }; + throw new SafeError("Error enabling Reply Zero"); } // Add rule to prompt file @@ -126,7 +127,7 @@ export async function enableReplyTracker({ email }: { email: string }) { // Update the rule to track replies if (!ruleId) { logger.error("Error enabling Reply Zero", { error: "No rule found" }); - return { error: "Error enabling Reply Zero" }; + throw new SafeError("Error enabling Reply Zero"); } const updatedRule = await prisma.rule.update({ diff --git a/apps/web/utils/user/validate.ts b/apps/web/utils/user/validate.ts index ad7f57105..99b8f8567 100644 --- a/apps/web/utils/user/validate.ts +++ b/apps/web/utils/user/validate.ts @@ -1,3 +1,4 @@ +import { SafeError } from "@/utils/error"; import { hasAiAccess } from "@/utils/premium"; import prisma from "@/utils/prisma"; @@ -18,13 +19,13 @@ export async function validateUserAndAiAccess({ user: { select: { premium: { select: { aiAutomationAccess: true } } } }, }, }); - if (!emailAccount) return { error: "User not found" }; + if (!emailAccount) throw new SafeError("User not found"); const userHasAiAccess = hasAiAccess( emailAccount.user.premium?.aiAutomationAccess, emailAccount.aiApiKey, ); - if (!userHasAiAccess) return { error: "Please upgrade for AI access" }; + if (!userHasAiAccess) throw new SafeError("Please upgrade for AI access"); return { emailAccount }; } From 6cff1a722313b415b9fb412183d645fa8063e305 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 12:06:17 +0300 Subject: [PATCH 062/176] fix infinite loop --- apps/web/app/(app)/assess.tsx | 7 ++----- apps/web/utils/auth.ts | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/web/app/(app)/assess.tsx b/apps/web/app/(app)/assess.tsx index 35d5e855f..8b2c296da 100644 --- a/apps/web/app/(app)/assess.tsx +++ b/apps/web/app/(app)/assess.tsx @@ -21,6 +21,7 @@ export function AssessUser() { analyzeWritingStyleAction.bind(null, email), ); + // biome-ignore lint/correctness/useExhaustiveDependencies: only run once useEffect(() => { async function assess() { const result = await executeAssessAsync(); @@ -32,11 +33,7 @@ export function AssessUser() { assess(); executeAnalyzeWritingStyle(); - }, [ - executeAssessAsync, - executeWhitelistInboxZero, - executeAnalyzeWritingStyle, - ]); + }, []); return null; } diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 663dbc562..7990fc98f 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -27,7 +27,7 @@ export const SCOPES = [ export const getAuthOptions: (options?: { consent: boolean; }) => NextAuthConfig = (options) => ({ - // debug: true, + debug: false, providers: [ GoogleProvider({ clientId: env.GOOGLE_CLIENT_ID, @@ -46,17 +46,17 @@ export const getAuthOptions: (options?: { }, }), ], - logger: { - error: (error) => { - logger.error(error.message, { error }); - }, - warn: (message) => { - logger.warn(message); - }, - debug: (message, metadata) => { - logger.info(message, { metadata }); - }, - }, + // logger: { + // error: (error) => { + // logger.error(error.message, { error }); + // }, + // warn: (message) => { + // logger.warn(message); + // }, + // debug: (message, metadata) => { + // logger.info(message, { metadata }); + // }, + // }, adapter: { ...PrismaAdapter(prisma), linkAccount: async (data: AdapterAccount): Promise => { From 2c8e8c08a4c9f9651d69b1c666ce3ee91bd515e8 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 13:47:09 +0300 Subject: [PATCH 063/176] add account id to path --- apps/web/app/(app)/PermissionsCheck.tsx | 25 --------- .../app/(app)/[account]/PermissionsCheck.tsx | 27 ++++++++++ apps/web/app/(app)/{ => [account]}/assess.tsx | 0 .../automation/AutomationOnboarding.tsx | 0 .../automation/BulkRunRules.tsx | 2 +- .../automation/ExecutedRulesTable.tsx | 2 +- .../{ => [account]}/automation/History.tsx | 4 +- .../{ => [account]}/automation/Pending.tsx | 4 +- .../automation/PersonaDialog.tsx | 2 +- .../{ => [account]}/automation/Process.tsx | 2 +- .../automation/ProcessResultDisplay.tsx | 0 .../automation/ProcessRules.tsx | 8 +-- .../automation/ProcessingPromptFileDialog.tsx | 0 .../automation/ReportMistake.tsx | 4 +- .../{ => [account]}/automation/RuleForm.tsx | 2 +- .../{ => [account]}/automation/Rules.tsx | 2 +- .../automation/RulesPrompt.tsx | 8 +-- .../automation/RulesSelect.tsx | 0 .../automation/SetDateDropdown.tsx | 0 .../automation/TestCustomEmailForm.tsx | 2 +- .../automation/create/examples.tsx | 0 .../automation/create/page.tsx | 2 +- .../{ => [account]}/automation/examples.ts | 0 .../automation/group/Groups.tsx | 0 .../automation/group/LearnedPatterns.tsx | 2 +- .../automation/group/ViewGroup.tsx | 0 .../group/[groupId]/examples/page.tsx | 2 +- .../automation/group/[groupId]/page.tsx | 2 +- .../automation/knowledge/KnowledgeBase.tsx | 2 +- .../automation/knowledge/KnowledgeForm.tsx | 0 .../automation/onboarding/CategoriesSetup.tsx | 0 .../automation/onboarding/completed/page.tsx | 0 .../onboarding/draft-replies/page.tsx | 0 .../automation/onboarding/page.tsx | 0 .../(app)/{ => [account]}/automation/page.tsx | 16 +++--- .../automation/rule/[ruleId]/error.tsx | 0 .../rule/[ruleId]/examples/example-list.tsx | 2 +- .../rule/[ruleId]/examples/page.tsx | 2 +- .../rule/[ruleId]/examples/types.ts | 0 .../automation/rule/[ruleId]/page.tsx | 2 +- .../automation/rule/create/page.tsx | 4 +- .../{ => [account]}/bulk-archive/page.tsx | 0 .../bulk-unsubscribe/ArchiveProgress.tsx | 0 .../bulk-unsubscribe/BulkActions.tsx | 4 +- .../bulk-unsubscribe/BulkUnsubscribe.tsx | 6 +-- .../BulkUnsubscribeDesktop.tsx | 4 +- .../BulkUnsubscribeMobile.tsx | 4 +- .../BulkUnsubscribeSection.tsx | 26 +++++----- .../BulkUnsubscribeSummary.tsx | 0 .../bulk-unsubscribe/SearchBar.tsx | 0 .../bulk-unsubscribe/ShortcutTooltip.tsx | 0 .../bulk-unsubscribe/common.tsx | 4 +- .../{ => [account]}/bulk-unsubscribe/hooks.ts | 2 +- .../{ => [account]}/bulk-unsubscribe/page.tsx | 2 +- .../{ => [account]}/bulk-unsubscribe/types.ts | 0 .../clean/ActionSelectionStep.tsx | 2 +- .../{ => [account]}/clean/CleanHistory.tsx | 0 .../clean/CleanInstructionsStep.tsx | 4 +- .../(app)/{ => [account]}/clean/CleanRun.tsx | 4 +- .../{ => [account]}/clean/CleanStats.tsx | 0 .../clean/ConfirmationStep.tsx | 2 +- .../{ => [account]}/clean/EmailFirehose.tsx | 0 .../clean/EmailFirehoseItem.tsx | 0 .../(app)/{ => [account]}/clean/IntroStep.tsx | 2 +- .../{ => [account]}/clean/PreviewBatch.tsx | 2 +- .../{ => [account]}/clean/TimeRangeStep.tsx | 4 +- .../app/(app)/{ => [account]}/clean/consts.ts | 0 .../(app)/{ => [account]}/clean/helpers.ts | 0 .../{ => [account]}/clean/history/page.tsx | 2 +- .../(app)/{ => [account]}/clean/loading.tsx | 0 .../{ => [account]}/clean/onboarding/page.tsx | 12 ++--- .../app/(app)/{ => [account]}/clean/page.tsx | 4 +- .../(app)/{ => [account]}/clean/run/page.tsx | 4 +- .../app/(app)/{ => [account]}/clean/types.ts | 0 .../{ => [account]}/clean/useEmailStream.ts | 0 .../{ => [account]}/clean/useSkipSettings.ts | 0 .../(app)/{ => [account]}/clean/useStep.tsx | 2 +- .../cold-email-blocker/ColdEmailList.tsx | 2 +- .../ColdEmailPromptForm.tsx | 0 .../cold-email-blocker/ColdEmailRejected.tsx | 2 +- .../cold-email-blocker/ColdEmailSettings.tsx | 2 +- .../cold-email-blocker/ColdEmailTest.tsx | 2 +- .../cold-email-blocker/TestRules.tsx | 0 .../cold-email-blocker/page.tsx | 10 ++-- .../compose/ComposeEmailForm.tsx | 0 .../compose/ComposeEmailFormLazy.tsx | 0 .../(app)/{ => [account]}/compose/page.tsx | 2 +- .../{ => [account]}/debug/drafts/page.tsx | 0 .../{ => [account]}/debug/learned/page.tsx | 2 +- .../app/(app)/{ => [account]}/debug/page.tsx | 0 .../early-access/EarlyAccessFeatures.tsx | 0 .../{ => [account]}/early-access/page.tsx | 2 +- .../(app)/{ => [account]}/mail/BetaBanner.tsx | 0 .../app/(app)/{ => [account]}/mail/page.tsx | 4 +- .../(app)/{ => [account]}/no-reply/page.tsx | 0 .../permissions/consent/page.tsx | 0 .../permissions/error/page.tsx | 0 .../{ => [account]}/premium/PremiumModal.tsx | 2 +- .../(app)/{ => [account]}/premium/Pricing.tsx | 2 +- .../(app)/{ => [account]}/premium/config.ts | 0 .../(app)/{ => [account]}/premium/page.tsx | 2 +- .../reply-zero/AwaitingReply.tsx | 0 .../reply-zero/EnableReplyTracker.tsx | 0 .../reply-zero/NeedsAction.tsx | 0 .../{ => [account]}/reply-zero/NeedsReply.tsx | 0 .../reply-zero/ReplyTrackerEmails.tsx | 0 .../{ => [account]}/reply-zero/Resolved.tsx | 0 .../reply-zero/TimeRangeFilter.tsx | 0 .../{ => [account]}/reply-zero/date-filter.ts | 0 .../reply-zero/fetch-trackers.ts | 0 .../reply-zero/onboarding/page.tsx | 2 +- .../(app)/{ => [account]}/reply-zero/page.tsx | 0 .../app/(app)/{ => [account]}/setup/page.tsx | 0 .../{ => [account]}/simple/SimpleList.tsx | 10 ++-- .../simple/SimpleModeOnboarding.tsx | 0 .../{ => [account]}/simple/SimpleProgress.tsx | 2 +- .../simple/SimpleProgressProvider.tsx | 0 .../(app)/{ => [account]}/simple/Summary.tsx | 2 +- .../{ => [account]}/simple/ViewMoreButton.tsx | 0 .../{ => [account]}/simple/categories.ts | 0 .../completed/OpenMultipleGmailButton.tsx | 0 .../simple/completed/ShareOnTwitterButton.tsx | 4 +- .../{ => [account]}/simple/completed/page.tsx | 6 +-- .../(app)/{ => [account]}/simple/layout.tsx | 2 +- .../(app)/{ => [account]}/simple/loading.tsx | 0 .../app/(app)/{ => [account]}/simple/page.tsx | 6 +-- .../smart-categories/CategorizeProgress.tsx | 0 .../CategorizeWithAiButton.tsx | 4 +- .../smart-categories/CreateCategoryButton.tsx | 0 .../smart-categories/Uncategorized.tsx | 2 +- .../{ => [account]}/smart-categories/page.tsx | 12 ++--- .../setup/SetUpCategories.tsx | 2 +- .../setup/SmartCategoriesOnboarding.tsx | 0 .../smart-categories/setup/page.tsx | 4 +- .../(app)/{ => [account]}/stats/ActionBar.tsx | 2 +- .../stats/CombinedStatsChart.tsx | 0 .../{ => [account]}/stats/DetailedStats.tsx | 4 +- .../stats/DetailedStatsFilter.tsx | 0 .../stats/EmailActionsAnalytics.tsx | 0 .../{ => [account]}/stats/EmailAnalytics.tsx | 4 +- .../stats/EmailsToIncludeFilter.tsx | 2 +- .../{ => [account]}/stats/LoadProgress.tsx | 0 .../{ => [account]}/stats/LoadStatsButton.tsx | 0 .../{ => [account]}/stats/NewsletterModal.tsx | 6 +-- .../app/(app)/{ => [account]}/stats/Stats.tsx | 18 +++---- .../{ => [account]}/stats/StatsChart.tsx | 0 .../{ => [account]}/stats/StatsOnboarding.tsx | 0 .../{ => [account]}/stats/StatsSummary.tsx | 0 .../app/(app)/{ => [account]}/stats/page.tsx | 2 +- .../app/(app)/{ => [account]}/stats/params.ts | 0 .../{ => [account]}/stats/useExpanded.tsx | 0 apps/web/app/(app)/layout.tsx | 2 +- .../onboarding/OnboardingBulkUnsubscriber.tsx | 2 +- .../onboarding/OnboardingColdEmailBlocker.tsx | 2 +- .../onboarding/OnboardingEmailAssistant.tsx | 2 +- apps/web/app/(app)/onboarding/page.tsx | 2 +- .../(app)/settings/MultiAccountSection.tsx | 4 +- apps/web/app/(landing)/ai-automation/page.tsx | 2 +- .../app/(landing)/block-cold-emails/page.tsx | 2 +- .../bulk-email-unsubscriber/page.tsx | 2 +- .../app/(landing)/email-analytics/page.tsx | 2 +- apps/web/app/(landing)/page.tsx | 2 +- apps/web/app/(landing)/reply-zero-ai/page.tsx | 2 +- .../app/(landing)/welcome-upgrade/page.tsx | 2 +- .../app/api/lemon-squeezy/webhook/route.ts | 2 +- .../group/[groupId]/messages/controller.ts | 2 +- .../api/user/rules/[id]/example/controller.ts | 2 +- apps/web/app/api/v1/reply-tracker/route.ts | 2 +- apps/web/components/AccountSwitcher.tsx | 51 ++++++++++++------- apps/web/components/GroupedTable.tsx | 2 +- apps/web/components/PremiumAlert.tsx | 4 +- .../components/email-list/EmailMessage.tsx | 4 +- apps/web/providers/AccountProvider.tsx | 20 ++++---- apps/web/providers/ComposeModalProvider.tsx | 2 +- apps/web/utils/actions/premium.ts | 2 +- apps/web/utils/rule/rule.ts | 2 +- 176 files changed, 249 insertions(+), 230 deletions(-) delete mode 100644 apps/web/app/(app)/PermissionsCheck.tsx create mode 100644 apps/web/app/(app)/[account]/PermissionsCheck.tsx rename apps/web/app/(app)/{ => [account]}/assess.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/AutomationOnboarding.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/BulkRunRules.tsx (98%) rename apps/web/app/(app)/{ => [account]}/automation/ExecutedRulesTable.tsx (98%) rename apps/web/app/(app)/{ => [account]}/automation/History.tsx (96%) rename apps/web/app/(app)/{ => [account]}/automation/Pending.tsx (98%) rename apps/web/app/(app)/{ => [account]}/automation/PersonaDialog.tsx (92%) rename apps/web/app/(app)/{ => [account]}/automation/Process.tsx (93%) rename apps/web/app/(app)/{ => [account]}/automation/ProcessResultDisplay.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/ProcessRules.tsx (96%) rename apps/web/app/(app)/{ => [account]}/automation/ProcessingPromptFileDialog.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/ReportMistake.tsx (99%) rename apps/web/app/(app)/{ => [account]}/automation/RuleForm.tsx (99%) rename apps/web/app/(app)/{ => [account]}/automation/Rules.tsx (99%) rename apps/web/app/(app)/{ => [account]}/automation/RulesPrompt.tsx (96%) rename apps/web/app/(app)/{ => [account]}/automation/RulesSelect.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/SetDateDropdown.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/TestCustomEmailForm.tsx (95%) rename apps/web/app/(app)/{ => [account]}/automation/create/examples.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/create/page.tsx (98%) rename apps/web/app/(app)/{ => [account]}/automation/examples.ts (100%) rename apps/web/app/(app)/{ => [account]}/automation/group/Groups.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/group/LearnedPatterns.tsx (95%) rename apps/web/app/(app)/{ => [account]}/automation/group/ViewGroup.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/group/[groupId]/examples/page.tsx (93%) rename apps/web/app/(app)/{ => [account]}/automation/group/[groupId]/page.tsx (82%) rename apps/web/app/(app)/{ => [account]}/automation/knowledge/KnowledgeBase.tsx (98%) rename apps/web/app/(app)/{ => [account]}/automation/knowledge/KnowledgeForm.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/onboarding/CategoriesSetup.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/onboarding/completed/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/onboarding/draft-replies/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/onboarding/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/page.tsx (85%) rename apps/web/app/(app)/{ => [account]}/automation/rule/[ruleId]/error.tsx (100%) rename apps/web/app/(app)/{ => [account]}/automation/rule/[ruleId]/examples/example-list.tsx (95%) rename apps/web/app/(app)/{ => [account]}/automation/rule/[ruleId]/examples/page.tsx (94%) rename apps/web/app/(app)/{ => [account]}/automation/rule/[ruleId]/examples/types.ts (100%) rename apps/web/app/(app)/{ => [account]}/automation/rule/[ruleId]/page.tsx (96%) rename apps/web/app/(app)/{ => [account]}/automation/rule/create/page.tsx (89%) rename apps/web/app/(app)/{ => [account]}/bulk-archive/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/ArchiveProgress.tsx (100%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/BulkActions.tsx (96%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/BulkUnsubscribe.tsx (91%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx (96%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/BulkUnsubscribeMobile.tsx (97%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/BulkUnsubscribeSection.tsx (90%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/BulkUnsubscribeSummary.tsx (100%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/SearchBar.tsx (100%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/ShortcutTooltip.tsx (100%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/common.tsx (99%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/hooks.ts (99%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/page.tsx (81%) rename apps/web/app/(app)/{ => [account]}/bulk-unsubscribe/types.ts (100%) rename apps/web/app/(app)/{ => [account]}/clean/ActionSelectionStep.tsx (94%) rename apps/web/app/(app)/{ => [account]}/clean/CleanHistory.tsx (100%) rename apps/web/app/(app)/{ => [account]}/clean/CleanInstructionsStep.tsx (95%) rename apps/web/app/(app)/{ => [account]}/clean/CleanRun.tsx (84%) rename apps/web/app/(app)/{ => [account]}/clean/CleanStats.tsx (100%) rename apps/web/app/(app)/{ => [account]}/clean/ConfirmationStep.tsx (98%) rename apps/web/app/(app)/{ => [account]}/clean/EmailFirehose.tsx (100%) rename apps/web/app/(app)/{ => [account]}/clean/EmailFirehoseItem.tsx (100%) rename apps/web/app/(app)/{ => [account]}/clean/IntroStep.tsx (96%) rename apps/web/app/(app)/{ => [account]}/clean/PreviewBatch.tsx (97%) rename apps/web/app/(app)/{ => [account]}/clean/TimeRangeStep.tsx (86%) rename apps/web/app/(app)/{ => [account]}/clean/consts.ts (100%) rename apps/web/app/(app)/{ => [account]}/clean/helpers.ts (100%) rename apps/web/app/(app)/{ => [account]}/clean/history/page.tsx (91%) rename apps/web/app/(app)/{ => [account]}/clean/loading.tsx (100%) rename apps/web/app/(app)/{ => [account]}/clean/onboarding/page.tsx (83%) rename apps/web/app/(app)/{ => [account]}/clean/page.tsx (86%) rename apps/web/app/(app)/{ => [account]}/clean/run/page.tsx (89%) rename apps/web/app/(app)/{ => [account]}/clean/types.ts (100%) rename apps/web/app/(app)/{ => [account]}/clean/useEmailStream.ts (100%) rename apps/web/app/(app)/{ => [account]}/clean/useSkipSettings.ts (100%) rename apps/web/app/(app)/{ => [account]}/clean/useStep.tsx (87%) rename apps/web/app/(app)/{ => [account]}/cold-email-blocker/ColdEmailList.tsx (98%) rename apps/web/app/(app)/{ => [account]}/cold-email-blocker/ColdEmailPromptForm.tsx (100%) rename apps/web/app/(app)/{ => [account]}/cold-email-blocker/ColdEmailRejected.tsx (97%) rename apps/web/app/(app)/{ => [account]}/cold-email-blocker/ColdEmailSettings.tsx (97%) rename apps/web/app/(app)/{ => [account]}/cold-email-blocker/ColdEmailTest.tsx (83%) rename apps/web/app/(app)/{ => [account]}/cold-email-blocker/TestRules.tsx (100%) rename apps/web/app/(app)/{ => [account]}/cold-email-blocker/page.tsx (79%) rename apps/web/app/(app)/{ => [account]}/compose/ComposeEmailForm.tsx (100%) rename apps/web/app/(app)/{ => [account]}/compose/ComposeEmailFormLazy.tsx (100%) rename apps/web/app/(app)/{ => [account]}/compose/page.tsx (75%) rename apps/web/app/(app)/{ => [account]}/debug/drafts/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/debug/learned/page.tsx (94%) rename apps/web/app/(app)/{ => [account]}/debug/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/early-access/EarlyAccessFeatures.tsx (100%) rename apps/web/app/(app)/{ => [account]}/early-access/page.tsx (96%) rename apps/web/app/(app)/{ => [account]}/mail/BetaBanner.tsx (100%) rename apps/web/app/(app)/{ => [account]}/mail/page.tsx (95%) rename apps/web/app/(app)/{ => [account]}/no-reply/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/permissions/consent/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/permissions/error/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/premium/PremiumModal.tsx (90%) rename apps/web/app/(app)/{ => [account]}/premium/Pricing.tsx (99%) rename apps/web/app/(app)/{ => [account]}/premium/config.ts (100%) rename apps/web/app/(app)/{ => [account]}/premium/page.tsx (74%) rename apps/web/app/(app)/{ => [account]}/reply-zero/AwaitingReply.tsx (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/EnableReplyTracker.tsx (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/NeedsAction.tsx (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/NeedsReply.tsx (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/ReplyTrackerEmails.tsx (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/Resolved.tsx (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/TimeRangeFilter.tsx (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/date-filter.ts (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/fetch-trackers.ts (100%) rename apps/web/app/(app)/{ => [account]}/reply-zero/onboarding/page.tsx (87%) rename apps/web/app/(app)/{ => [account]}/reply-zero/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/setup/page.tsx (100%) rename apps/web/app/(app)/{ => [account]}/simple/SimpleList.tsx (96%) rename apps/web/app/(app)/{ => [account]}/simple/SimpleModeOnboarding.tsx (100%) rename apps/web/app/(app)/{ => [account]}/simple/SimpleProgress.tsx (96%) rename apps/web/app/(app)/{ => [account]}/simple/SimpleProgressProvider.tsx (100%) rename apps/web/app/(app)/{ => [account]}/simple/Summary.tsx (91%) rename apps/web/app/(app)/{ => [account]}/simple/ViewMoreButton.tsx (100%) rename apps/web/app/(app)/{ => [account]}/simple/categories.ts (100%) rename apps/web/app/(app)/{ => [account]}/simple/completed/OpenMultipleGmailButton.tsx (100%) rename apps/web/app/(app)/{ => [account]}/simple/completed/ShareOnTwitterButton.tsx (84%) rename apps/web/app/(app)/{ => [account]}/simple/completed/page.tsx (86%) rename apps/web/app/(app)/{ => [account]}/simple/layout.tsx (64%) rename apps/web/app/(app)/{ => [account]}/simple/loading.tsx (100%) rename apps/web/app/(app)/{ => [account]}/simple/page.tsx (93%) rename apps/web/app/(app)/{ => [account]}/smart-categories/CategorizeProgress.tsx (100%) rename apps/web/app/(app)/{ => [account]}/smart-categories/CategorizeWithAiButton.tsx (94%) rename apps/web/app/(app)/{ => [account]}/smart-categories/CreateCategoryButton.tsx (100%) rename apps/web/app/(app)/{ => [account]}/smart-categories/Uncategorized.tsx (98%) rename apps/web/app/(app)/{ => [account]}/smart-categories/page.tsx (88%) rename apps/web/app/(app)/{ => [account]}/smart-categories/setup/SetUpCategories.tsx (99%) rename apps/web/app/(app)/{ => [account]}/smart-categories/setup/SmartCategoriesOnboarding.tsx (100%) rename apps/web/app/(app)/{ => [account]}/smart-categories/setup/page.tsx (73%) rename apps/web/app/(app)/{ => [account]}/stats/ActionBar.tsx (96%) rename apps/web/app/(app)/{ => [account]}/stats/CombinedStatsChart.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/DetailedStats.tsx (97%) rename apps/web/app/(app)/{ => [account]}/stats/DetailedStatsFilter.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/EmailActionsAnalytics.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/EmailAnalytics.tsx (95%) rename apps/web/app/(app)/{ => [account]}/stats/EmailsToIncludeFilter.tsx (95%) rename apps/web/app/(app)/{ => [account]}/stats/LoadProgress.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/LoadStatsButton.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/NewsletterModal.tsx (96%) rename apps/web/app/(app)/{ => [account]}/stats/Stats.tsx (81%) rename apps/web/app/(app)/{ => [account]}/stats/StatsChart.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/StatsOnboarding.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/StatsSummary.tsx (100%) rename apps/web/app/(app)/{ => [account]}/stats/page.tsx (79%) rename apps/web/app/(app)/{ => [account]}/stats/params.ts (100%) rename apps/web/app/(app)/{ => [account]}/stats/useExpanded.tsx (100%) diff --git a/apps/web/app/(app)/PermissionsCheck.tsx b/apps/web/app/(app)/PermissionsCheck.tsx deleted file mode 100644 index 53188ab9e..000000000 --- a/apps/web/app/(app)/PermissionsCheck.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { checkPermissionsAction } from "@/utils/actions/permissions"; - -let permissionsChecked = false; - -export function PermissionsCheck() { - const router = useRouter(); - - useEffect(() => { - if (permissionsChecked) return; - permissionsChecked = true; - - checkPermissionsAction().then((result) => { - if (result?.hasAllPermissions === false) - router.replace("/permissions/error"); - if (result?.hasRefreshToken === false) - router.replace("/permissions/consent"); - }); - }, [router]); - - return null; -} diff --git a/apps/web/app/(app)/[account]/PermissionsCheck.tsx b/apps/web/app/(app)/[account]/PermissionsCheck.tsx new file mode 100644 index 000000000..35459b289 --- /dev/null +++ b/apps/web/app/(app)/[account]/PermissionsCheck.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { checkPermissionsAction } from "@/utils/actions/permissions"; +import { useAccount } from "@/providers/AccountProvider"; + +const permissionsChecked: Record = {}; + +export function PermissionsCheck() { + const router = useRouter(); + const { email } = useAccount(); + + useEffect(() => { + if (permissionsChecked[email]) return; + permissionsChecked[email] = true; + + checkPermissionsAction(email).then((result) => { + if (result?.data?.hasAllPermissions === false) + router.replace("/permissions/error"); + if (result?.data?.hasRefreshToken === false) + router.replace("/permissions/consent"); + }); + }, [router, email]); + + return null; +} diff --git a/apps/web/app/(app)/assess.tsx b/apps/web/app/(app)/[account]/assess.tsx similarity index 100% rename from apps/web/app/(app)/assess.tsx rename to apps/web/app/(app)/[account]/assess.tsx diff --git a/apps/web/app/(app)/automation/AutomationOnboarding.tsx b/apps/web/app/(app)/[account]/automation/AutomationOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/automation/AutomationOnboarding.tsx rename to apps/web/app/(app)/[account]/automation/AutomationOnboarding.tsx diff --git a/apps/web/app/(app)/automation/BulkRunRules.tsx b/apps/web/app/(app)/[account]/automation/BulkRunRules.tsx similarity index 98% rename from apps/web/app/(app)/automation/BulkRunRules.tsx rename to apps/web/app/(app)/[account]/automation/BulkRunRules.tsx index 272802a32..34ca64a19 100644 --- a/apps/web/app/(app)/automation/BulkRunRules.tsx +++ b/apps/web/app/(app)/[account]/automation/BulkRunRules.tsx @@ -11,7 +11,7 @@ import { LoadingContent } from "@/components/LoadingContent"; import { runAiRules } from "@/utils/queue/email-actions"; import { sleep } from "@/utils/sleep"; import { PremiumAlertWithData, usePremium } from "@/components/PremiumAlert"; -import { SetDateDropdown } from "@/app/(app)/automation/SetDateDropdown"; +import { SetDateDropdown } from "@/app/(app)/[account]/automation/SetDateDropdown"; import { dateToSeconds } from "@/utils/date"; import { useThreads } from "@/hooks/useThreads"; import { useAiQueueState } from "@/store/ai-queue"; diff --git a/apps/web/app/(app)/automation/ExecutedRulesTable.tsx b/apps/web/app/(app)/[account]/automation/ExecutedRulesTable.tsx similarity index 98% rename from apps/web/app/(app)/automation/ExecutedRulesTable.tsx rename to apps/web/app/(app)/[account]/automation/ExecutedRulesTable.tsx index faa234ece..857af25c5 100644 --- a/apps/web/app/(app)/automation/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[account]/automation/ExecutedRulesTable.tsx @@ -12,7 +12,7 @@ import { Badge } from "@/components/Badge"; import { Button } from "@/components/ui/button"; import { conditionsToString, conditionTypesToString } from "@/utils/condition"; import { MessageText } from "@/components/Typography"; -import { ReportMistake } from "@/app/(app)/automation/ReportMistake"; +import { ReportMistake } from "@/app/(app)/[account]/automation/ReportMistake"; import type { ParsedMessage } from "@/utils/types"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { ExecutedRuleStatus } from "@prisma/client"; diff --git a/apps/web/app/(app)/automation/History.tsx b/apps/web/app/(app)/[account]/automation/History.tsx similarity index 96% rename from apps/web/app/(app)/automation/History.tsx rename to apps/web/app/(app)/[account]/automation/History.tsx index 5aded29c7..c50d11ed5 100644 --- a/apps/web/app/(app)/automation/History.tsx +++ b/apps/web/app/(app)/[account]/automation/History.tsx @@ -19,10 +19,10 @@ import { DateCell, EmailCell, RuleCell, -} from "@/app/(app)/automation/ExecutedRulesTable"; +} from "@/app/(app)/[account]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { Badge } from "@/components/Badge"; -import { RulesSelect } from "@/app/(app)/automation/RulesSelect"; +import { RulesSelect } from "@/app/(app)/[account]/automation/RulesSelect"; import { useAccount } from "@/providers/AccountProvider"; export function History() { diff --git a/apps/web/app/(app)/automation/Pending.tsx b/apps/web/app/(app)/[account]/automation/Pending.tsx similarity index 98% rename from apps/web/app/(app)/automation/Pending.tsx rename to apps/web/app/(app)/[account]/automation/Pending.tsx index 7c2331cc4..6bd7b9e93 100644 --- a/apps/web/app/(app)/automation/Pending.tsx +++ b/apps/web/app/(app)/[account]/automation/Pending.tsx @@ -24,11 +24,11 @@ import { ActionItemsCell, EmailCell, RuleCell, -} from "@/app/(app)/automation/ExecutedRulesTable"; +} from "@/app/(app)/[account]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { Checkbox } from "@/components/Checkbox"; import { useToggleSelect } from "@/hooks/useToggleSelect"; -import { RulesSelect } from "@/app/(app)/automation/RulesSelect"; +import { RulesSelect } from "@/app/(app)/[account]/automation/RulesSelect"; import { useAccount } from "@/providers/AccountProvider"; export function Pending() { diff --git a/apps/web/app/(app)/automation/PersonaDialog.tsx b/apps/web/app/(app)/[account]/automation/PersonaDialog.tsx similarity index 92% rename from apps/web/app/(app)/automation/PersonaDialog.tsx rename to apps/web/app/(app)/[account]/automation/PersonaDialog.tsx index ea55df07a..7412e5319 100644 --- a/apps/web/app/(app)/automation/PersonaDialog.tsx +++ b/apps/web/app/(app)/[account]/automation/PersonaDialog.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { ButtonList } from "@/components/ButtonList"; -import { personas } from "@/app/(app)/automation/examples"; +import { personas } from "@/app/(app)/[account]/automation/examples"; export function PersonaDialog({ isOpen, diff --git a/apps/web/app/(app)/automation/Process.tsx b/apps/web/app/(app)/[account]/automation/Process.tsx similarity index 93% rename from apps/web/app/(app)/automation/Process.tsx rename to apps/web/app/(app)/[account]/automation/Process.tsx index 157cba0ca..7c0cefbe4 100644 --- a/apps/web/app/(app)/automation/Process.tsx +++ b/apps/web/app/(app)/[account]/automation/Process.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryState } from "nuqs"; -import { ProcessRulesContent } from "@/app/(app)/automation/ProcessRules"; +import { ProcessRulesContent } from "@/app/(app)/[account]/automation/ProcessRules"; import { Toggle } from "@/components/Toggle"; import { Card, diff --git a/apps/web/app/(app)/automation/ProcessResultDisplay.tsx b/apps/web/app/(app)/[account]/automation/ProcessResultDisplay.tsx similarity index 100% rename from apps/web/app/(app)/automation/ProcessResultDisplay.tsx rename to apps/web/app/(app)/[account]/automation/ProcessResultDisplay.tsx diff --git a/apps/web/app/(app)/automation/ProcessRules.tsx b/apps/web/app/(app)/[account]/automation/ProcessRules.tsx similarity index 96% rename from apps/web/app/(app)/automation/ProcessRules.tsx rename to apps/web/app/(app)/[account]/automation/ProcessRules.tsx index 6ddd9468a..86b7c7866 100644 --- a/apps/web/app/(app)/automation/ProcessRules.tsx +++ b/apps/web/app/(app)/[account]/automation/ProcessRules.tsx @@ -24,7 +24,7 @@ import { Table, TableBody, TableRow, TableCell } from "@/components/ui/table"; import { CardContent } from "@/components/ui/card"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; import { SearchForm } from "@/components/SearchForm"; -import { ReportMistake } from "@/app/(app)/automation/ReportMistake"; +import { ReportMistake } from "@/app/(app)/[account]/automation/ReportMistake"; import { Badge } from "@/components/Badge"; import { isAIRule, @@ -32,10 +32,10 @@ import { isGroupRule, isStaticRule, } from "@/utils/condition"; -import { BulkRunRules } from "@/app/(app)/automation/BulkRunRules"; +import { BulkRunRules } from "@/app/(app)/[account]/automation/BulkRunRules"; import { cn } from "@/utils"; -import { TestCustomEmailForm } from "@/app/(app)/automation/TestCustomEmailForm"; -import { ProcessResultDisplay } from "@/app/(app)/automation/ProcessResultDisplay"; +import { TestCustomEmailForm } from "@/app/(app)/[account]/automation/TestCustomEmailForm"; +import { ProcessResultDisplay } from "@/app/(app)/[account]/automation/ProcessResultDisplay"; import { Tooltip } from "@/components/Tooltip"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx b/apps/web/app/(app)/[account]/automation/ProcessingPromptFileDialog.tsx similarity index 100% rename from apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx rename to apps/web/app/(app)/[account]/automation/ProcessingPromptFileDialog.tsx diff --git a/apps/web/app/(app)/automation/ReportMistake.tsx b/apps/web/app/(app)/[account]/automation/ReportMistake.tsx similarity index 99% rename from apps/web/app/(app)/automation/ReportMistake.tsx rename to apps/web/app/(app)/[account]/automation/ReportMistake.tsx index 03fade936..c04bf962b 100644 --- a/apps/web/app/(app)/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/[account]/automation/ReportMistake.tsx @@ -44,7 +44,7 @@ import { updateRuleInstructionsAction } from "@/utils/actions/rule"; import { Separator } from "@/components/ui/separator"; import { SectionDescription } from "@/components/Typography"; import { Badge } from "@/components/Badge"; -import { ProcessResultDisplay } from "@/app/(app)/automation/ProcessResultDisplay"; +import { ProcessResultDisplay } from "@/app/(app)/[account]/automation/ProcessResultDisplay"; import { isReplyInThread } from "@/utils/thread"; import { isAIRule, isGroupRule, isStaticRule } from "@/utils/condition"; import { Loading, LoadingMiniSpinner } from "@/components/Loading"; @@ -55,7 +55,7 @@ import { } from "@/utils/actions/group"; import { useRules } from "@/hooks/useRules"; import type { CategoryMatch, GroupMatch } from "@/utils/ai/choose-rule/types"; -import { GroupItemDisplay } from "@/app/(app)/automation/group/ViewGroup"; +import { GroupItemDisplay } from "@/app/(app)/[account]/automation/group/ViewGroup"; import { cn } from "@/utils"; import { useCategories } from "@/hooks/useCategories"; import { CategorySelect } from "@/components/CategorySelect"; diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/[account]/automation/RuleForm.tsx similarity index 99% rename from apps/web/app/(app)/automation/RuleForm.tsx rename to apps/web/app/(app)/[account]/automation/RuleForm.tsx index 9cdf0828f..f9e7818d4 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/[account]/automation/RuleForm.tsx @@ -61,7 +61,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { LearnedPatterns } from "@/app/(app)/automation/group/LearnedPatterns"; +import { LearnedPatterns } from "@/app/(app)/[account]/automation/group/LearnedPatterns"; import { Tooltip } from "@/components/Tooltip"; import { createGroupAction } from "@/utils/actions/group"; import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; diff --git a/apps/web/app/(app)/automation/Rules.tsx b/apps/web/app/(app)/[account]/automation/Rules.tsx similarity index 99% rename from apps/web/app/(app)/automation/Rules.tsx rename to apps/web/app/(app)/[account]/automation/Rules.tsx index 3b09998ec..9910fa562 100644 --- a/apps/web/app/(app)/automation/Rules.tsx +++ b/apps/web/app/(app)/[account]/automation/Rules.tsx @@ -42,7 +42,7 @@ import { Tooltip } from "@/components/Tooltip"; import type { RiskLevel } from "@/utils/risk"; import { useRules } from "@/hooks/useRules"; import { ActionType } from "@prisma/client"; -import { ThreadsExplanation } from "@/app/(app)/automation/RuleForm"; +import { ThreadsExplanation } from "@/app/(app)/[account]/automation/RuleForm"; import { useAction } from "next-safe-action/hooks"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/automation/RulesPrompt.tsx b/apps/web/app/(app)/[account]/automation/RulesPrompt.tsx similarity index 96% rename from apps/web/app/(app)/automation/RulesPrompt.tsx rename to apps/web/app/(app)/[account]/automation/RulesPrompt.tsx index bb94568ac..63fc59f9b 100644 --- a/apps/web/app/(app)/automation/RulesPrompt.tsx +++ b/apps/web/app/(app)/[account]/automation/RulesPrompt.tsx @@ -25,11 +25,11 @@ import type { RulesPromptResponse } from "@/app/api/user/rules/prompt/route"; import { LoadingContent } from "@/components/LoadingContent"; import { Tooltip } from "@/components/Tooltip"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; -import { AutomationOnboarding } from "@/app/(app)/automation/AutomationOnboarding"; -import { examplePrompts, personas } from "@/app/(app)/automation/examples"; -import { PersonaDialog } from "@/app/(app)/automation/PersonaDialog"; +import { AutomationOnboarding } from "@/app/(app)/[account]/automation/AutomationOnboarding"; +import { examplePrompts, personas } from "@/app/(app)/[account]/automation/examples"; +import { PersonaDialog } from "@/app/(app)/[account]/automation/PersonaDialog"; import { useModal } from "@/hooks/useModal"; -import { ProcessingPromptFileDialog } from "@/app/(app)/automation/ProcessingPromptFileDialog"; +import { ProcessingPromptFileDialog } from "@/app/(app)/[account]/automation/ProcessingPromptFileDialog"; import { AlertBasic } from "@/components/Alert"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/automation/RulesSelect.tsx b/apps/web/app/(app)/[account]/automation/RulesSelect.tsx similarity index 100% rename from apps/web/app/(app)/automation/RulesSelect.tsx rename to apps/web/app/(app)/[account]/automation/RulesSelect.tsx diff --git a/apps/web/app/(app)/automation/SetDateDropdown.tsx b/apps/web/app/(app)/[account]/automation/SetDateDropdown.tsx similarity index 100% rename from apps/web/app/(app)/automation/SetDateDropdown.tsx rename to apps/web/app/(app)/[account]/automation/SetDateDropdown.tsx diff --git a/apps/web/app/(app)/automation/TestCustomEmailForm.tsx b/apps/web/app/(app)/[account]/automation/TestCustomEmailForm.tsx similarity index 95% rename from apps/web/app/(app)/automation/TestCustomEmailForm.tsx rename to apps/web/app/(app)/[account]/automation/TestCustomEmailForm.tsx index 130ba80c0..785c4d7dc 100644 --- a/apps/web/app/(app)/automation/TestCustomEmailForm.tsx +++ b/apps/web/app/(app)/[account]/automation/TestCustomEmailForm.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/Input"; import { toastError } from "@/components/Toast"; import { testAiCustomContentAction } from "@/utils/actions/ai-rule"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; -import { ProcessResultDisplay } from "@/app/(app)/automation/ProcessResultDisplay"; +import { ProcessResultDisplay } from "@/app/(app)/[account]/automation/ProcessResultDisplay"; import { testAiCustomContentBody, type TestAiCustomContentBody, diff --git a/apps/web/app/(app)/automation/create/examples.tsx b/apps/web/app/(app)/[account]/automation/create/examples.tsx similarity index 100% rename from apps/web/app/(app)/automation/create/examples.tsx rename to apps/web/app/(app)/[account]/automation/create/examples.tsx diff --git a/apps/web/app/(app)/automation/create/page.tsx b/apps/web/app/(app)/[account]/automation/create/page.tsx similarity index 98% rename from apps/web/app/(app)/automation/create/page.tsx rename to apps/web/app/(app)/[account]/automation/create/page.tsx index 177894f74..981923404 100644 --- a/apps/web/app/(app)/automation/create/page.tsx +++ b/apps/web/app/(app)/[account]/automation/create/page.tsx @@ -15,7 +15,7 @@ import { import { Button } from "@/components/ui/button"; import { createAutomationAction } from "@/utils/actions/ai-rule"; import { toastError } from "@/components/Toast"; -import { examples } from "@/app/(app)/automation/create/examples"; +import { examples } from "@/app/(app)/[account]/automation/create/examples"; import { useAccount } from "@/providers/AccountProvider"; import type { CreateAutomationBody } from "@/utils/actions/ai-rule.validation"; diff --git a/apps/web/app/(app)/automation/examples.ts b/apps/web/app/(app)/[account]/automation/examples.ts similarity index 100% rename from apps/web/app/(app)/automation/examples.ts rename to apps/web/app/(app)/[account]/automation/examples.ts diff --git a/apps/web/app/(app)/automation/group/Groups.tsx b/apps/web/app/(app)/[account]/automation/group/Groups.tsx similarity index 100% rename from apps/web/app/(app)/automation/group/Groups.tsx rename to apps/web/app/(app)/[account]/automation/group/Groups.tsx diff --git a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx b/apps/web/app/(app)/[account]/automation/group/LearnedPatterns.tsx similarity index 95% rename from apps/web/app/(app)/automation/group/LearnedPatterns.tsx rename to apps/web/app/(app)/[account]/automation/group/LearnedPatterns.tsx index 6a6d5bce4..78a54cdf4 100644 --- a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx +++ b/apps/web/app/(app)/[account]/automation/group/LearnedPatterns.tsx @@ -7,7 +7,7 @@ import { CollapsibleTrigger, CollapsibleContent, } from "@/components/ui/collapsible"; -import { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; +import { ViewGroup } from "@/app/(app)/[account]/automation/group/ViewGroup"; export function LearnedPatterns({ groupId }: { groupId: string }) { const [isOpen, setIsOpen] = useState(false); diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/[account]/automation/group/ViewGroup.tsx similarity index 100% rename from apps/web/app/(app)/automation/group/ViewGroup.tsx rename to apps/web/app/(app)/[account]/automation/group/ViewGroup.tsx diff --git a/apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx b/apps/web/app/(app)/[account]/automation/group/[groupId]/examples/page.tsx similarity index 93% rename from apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx rename to apps/web/app/(app)/[account]/automation/group/[groupId]/examples/page.tsx index 84cd831f6..04096398c 100644 --- a/apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx +++ b/apps/web/app/(app)/[account]/automation/group/[groupId]/examples/page.tsx @@ -4,7 +4,7 @@ import useSWR from "swr"; import { use } from "react"; import groupBy from "lodash/groupBy"; import { TopSection } from "@/components/TopSection"; -import { ExampleList } from "@/app/(app)/automation/rule/[ruleId]/examples/example-list"; +import { ExampleList } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list"; import type { GroupEmailsResponse } from "@/app/api/user/group/[groupId]/messages/controller"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/automation/group/[groupId]/page.tsx b/apps/web/app/(app)/[account]/automation/group/[groupId]/page.tsx similarity index 82% rename from apps/web/app/(app)/automation/group/[groupId]/page.tsx rename to apps/web/app/(app)/[account]/automation/group/[groupId]/page.tsx index 9387e9c84..1a5c06457 100644 --- a/apps/web/app/(app)/automation/group/[groupId]/page.tsx +++ b/apps/web/app/(app)/[account]/automation/group/[groupId]/page.tsx @@ -1,4 +1,4 @@ -import { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; +import { ViewGroup } from "@/app/(app)/[account]/automation/group/ViewGroup"; import { Container } from "@/components/Container"; // Not in use anymore. Could delete this. diff --git a/apps/web/app/(app)/automation/knowledge/KnowledgeBase.tsx b/apps/web/app/(app)/[account]/automation/knowledge/KnowledgeBase.tsx similarity index 98% rename from apps/web/app/(app)/automation/knowledge/KnowledgeBase.tsx rename to apps/web/app/(app)/[account]/automation/knowledge/KnowledgeBase.tsx index 06538e4d4..b8e99f459 100644 --- a/apps/web/app/(app)/automation/knowledge/KnowledgeBase.tsx +++ b/apps/web/app/(app)/[account]/automation/knowledge/KnowledgeBase.tsx @@ -27,7 +27,7 @@ import type { GetKnowledgeResponse } from "@/app/api/knowledge/route"; import { formatDateSimple } from "@/utils/date"; import type { Knowledge } from "@prisma/client"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { KnowledgeForm } from "@/app/(app)/automation/knowledge/KnowledgeForm"; +import { KnowledgeForm } from "@/app/(app)/[account]/automation/knowledge/KnowledgeForm"; import { useAccount } from "@/providers/AccountProvider"; export function KnowledgeBase() { diff --git a/apps/web/app/(app)/automation/knowledge/KnowledgeForm.tsx b/apps/web/app/(app)/[account]/automation/knowledge/KnowledgeForm.tsx similarity index 100% rename from apps/web/app/(app)/automation/knowledge/KnowledgeForm.tsx rename to apps/web/app/(app)/[account]/automation/knowledge/KnowledgeForm.tsx diff --git a/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx b/apps/web/app/(app)/[account]/automation/onboarding/CategoriesSetup.tsx similarity index 100% rename from apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx rename to apps/web/app/(app)/[account]/automation/onboarding/CategoriesSetup.tsx diff --git a/apps/web/app/(app)/automation/onboarding/completed/page.tsx b/apps/web/app/(app)/[account]/automation/onboarding/completed/page.tsx similarity index 100% rename from apps/web/app/(app)/automation/onboarding/completed/page.tsx rename to apps/web/app/(app)/[account]/automation/onboarding/completed/page.tsx diff --git a/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx b/apps/web/app/(app)/[account]/automation/onboarding/draft-replies/page.tsx similarity index 100% rename from apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx rename to apps/web/app/(app)/[account]/automation/onboarding/draft-replies/page.tsx diff --git a/apps/web/app/(app)/automation/onboarding/page.tsx b/apps/web/app/(app)/[account]/automation/onboarding/page.tsx similarity index 100% rename from apps/web/app/(app)/automation/onboarding/page.tsx rename to apps/web/app/(app)/[account]/automation/onboarding/page.tsx diff --git a/apps/web/app/(app)/automation/page.tsx b/apps/web/app/(app)/[account]/automation/page.tsx similarity index 85% rename from apps/web/app/(app)/automation/page.tsx rename to apps/web/app/(app)/[account]/automation/page.tsx index e8872e8cb..e2032f721 100644 --- a/apps/web/app/(app)/automation/page.tsx +++ b/apps/web/app/(app)/[account]/automation/page.tsx @@ -3,17 +3,17 @@ import Link from "next/link"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import prisma from "@/utils/prisma"; -import { History } from "@/app/(app)/automation/History"; -import { Pending } from "@/app/(app)/automation/Pending"; +import { History } from "@/app/(app)/[account]/automation/History"; +import { Pending } from "@/app/(app)/[account]/automation/Pending"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { Rules } from "@/app/(app)/automation/Rules"; -import { Process } from "@/app/(app)/automation/Process"; -import { KnowledgeBase } from "@/app/(app)/automation/knowledge/KnowledgeBase"; -import { Groups } from "@/app/(app)/automation/group/Groups"; -import { RulesPrompt } from "@/app/(app)/automation/RulesPrompt"; +import { Rules } from "@/app/(app)/[account]/automation/Rules"; +import { Process } from "@/app/(app)/[account]/automation/Process"; +import { KnowledgeBase } from "@/app/(app)/[account]/automation/knowledge/KnowledgeBase"; +import { Groups } from "@/app/(app)/[account]/automation/group/Groups"; +import { RulesPrompt } from "@/app/(app)/[account]/automation/RulesPrompt"; import { OnboardingModal } from "@/components/OnboardingModal"; -import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; import { TabsToolbar } from "@/components/TabsToolbar"; import { GmailProvider } from "@/providers/GmailProvider"; import { ASSISTANT_ONBOARDING_COOKIE } from "@/utils/cookies"; diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/error.tsx b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/error.tsx similarity index 100% rename from apps/web/app/(app)/automation/rule/[ruleId]/error.tsx rename to apps/web/app/(app)/[account]/automation/rule/[ruleId]/error.tsx diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list.tsx similarity index 95% rename from apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx rename to apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list.tsx index aae104490..25aec1a0e 100644 --- a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx +++ b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list.tsx @@ -6,7 +6,7 @@ import type { Dictionary } from "lodash"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { deleteGroupItemAction } from "@/utils/actions/group"; -import type { MessageWithGroupItem } from "@/app/(app)/automation/rule/[ruleId]/examples/types"; +import type { MessageWithGroupItem } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/types"; import { toastError } from "@/components/Toast"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/examples/page.tsx b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/page.tsx similarity index 94% rename from apps/web/app/(app)/automation/rule/[ruleId]/examples/page.tsx rename to apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/page.tsx index 5eb3617ce..04a91d35e 100644 --- a/apps/web/app/(app)/automation/rule/[ruleId]/examples/page.tsx +++ b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/page.tsx @@ -6,7 +6,7 @@ import useSWR from "swr"; import groupBy from "lodash/groupBy"; import { TopSection } from "@/components/TopSection"; import { Button } from "@/components/ui/button"; -import { ExampleList } from "@/app/(app)/automation/rule/[ruleId]/examples/example-list"; +import { ExampleList } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list"; import type { ExamplesResponse } from "@/app/api/user/rules/[id]/example/route"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/examples/types.ts b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/types.ts similarity index 100% rename from apps/web/app/(app)/automation/rule/[ruleId]/examples/types.ts rename to apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/types.ts diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/page.tsx b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx similarity index 96% rename from apps/web/app/(app)/automation/rule/[ruleId]/page.tsx rename to apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx index af9735549..a32cca7b5 100644 --- a/apps/web/app/(app)/automation/rule/[ruleId]/page.tsx +++ b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import prisma from "@/utils/prisma"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { RuleForm } from "@/app/(app)/automation/RuleForm"; +import { RuleForm } from "@/app/(app)/[account]/automation/RuleForm"; import { TopSection } from "@/components/TopSection"; import { hasVariables } from "@/utils/template"; import { getConditions } from "@/utils/condition"; diff --git a/apps/web/app/(app)/automation/rule/create/page.tsx b/apps/web/app/(app)/[account]/automation/rule/create/page.tsx similarity index 89% rename from apps/web/app/(app)/automation/rule/create/page.tsx rename to apps/web/app/(app)/[account]/automation/rule/create/page.tsx index 85d38d79f..caf55b8e7 100644 --- a/apps/web/app/(app)/automation/rule/create/page.tsx +++ b/apps/web/app/(app)/[account]/automation/rule/create/page.tsx @@ -1,5 +1,5 @@ -import { RuleForm } from "@/app/(app)/automation/RuleForm"; -import { examples } from "@/app/(app)/automation/create/examples"; +import { RuleForm } from "@/app/(app)/[account]/automation/RuleForm"; +import { examples } from "@/app/(app)/[account]/automation/create/examples"; import { getEmptyCondition } from "@/utils/condition"; import { ActionType } from "@prisma/client"; import type { CoreConditionType } from "@/utils/config"; diff --git a/apps/web/app/(app)/bulk-archive/page.tsx b/apps/web/app/(app)/[account]/bulk-archive/page.tsx similarity index 100% rename from apps/web/app/(app)/bulk-archive/page.tsx rename to apps/web/app/(app)/[account]/bulk-archive/page.tsx diff --git a/apps/web/app/(app)/bulk-unsubscribe/ArchiveProgress.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress.tsx similarity index 100% rename from apps/web/app/(app)/bulk-unsubscribe/ArchiveProgress.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress.tsx diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkActions.tsx similarity index 96% rename from apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/BulkActions.tsx index 1d580621d..d07de58fa 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkActions.tsx @@ -11,10 +11,10 @@ import { useBulkAutoArchive, useBulkArchive, useBulkDelete, -} from "@/app/(app)/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; import { Button } from "@/components/ui/button"; -import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; export function BulkActions({ selected, diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribe.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribe.tsx similarity index 91% rename from apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribe.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribe.tsx index 37227773f..74124fa11 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribe.tsx +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribe.tsx @@ -4,9 +4,9 @@ import subDays from "date-fns/subDays"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useWindowSize } from "usehooks-ts"; import type { DateRange } from "react-day-picker"; -import { BulkUnsubscribeSection } from "@/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection"; -import { LoadStatsButton } from "@/app/(app)/stats/LoadStatsButton"; -import { ActionBar } from "@/app/(app)/stats/ActionBar"; +import { BulkUnsubscribeSection } from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection"; +import { LoadStatsButton } from "@/app/(app)/[account]/stats/LoadStatsButton"; +import { ActionBar } from "@/app/(app)/[account]/stats/ActionBar"; import { useStatLoader } from "@/providers/StatLoaderProvider"; import { OnboardingModal } from "@/components/OnboardingModal"; import { TextLink } from "@/components/Typography"; diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx similarity index 96% rename from apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index 93465c750..e6f5e4f5f 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -10,8 +10,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { ActionCell, HeaderButton } from "@/app/(app)/bulk-unsubscribe/common"; -import type { RowProps } from "@/app/(app)/bulk-unsubscribe/types"; +import { ActionCell, HeaderButton } from "@/app/(app)/[account]/bulk-unsubscribe/common"; +import type { RowProps } from "@/app/(app)/[account]/bulk-unsubscribe/types"; import { Checkbox } from "@/components/Checkbox"; export function BulkUnsubscribeDesktop({ diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx similarity index 97% rename from apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx index cafe64522..efd0e37aa 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx @@ -13,7 +13,7 @@ import { useUnsubscribe, useApproveButton, useArchiveAll, -} from "@/app/(app)/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; import { Card, CardContent, @@ -22,7 +22,7 @@ import { CardTitle, } from "@/components/ui/card"; import { extractEmailAddress, extractNameFromEmail } from "@/utils/email"; -import type { RowProps } from "@/app/(app)/bulk-unsubscribe/types"; +import type { RowProps } from "@/app/(app)/[account]/bulk-unsubscribe/types"; import { Button } from "@/components/ui/button"; import { ButtonLoader } from "@/components/Loading"; import { NewsletterStatus } from "@prisma/client"; diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection.tsx similarity index 90% rename from apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index 016173cea..a977a4031 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -13,33 +13,33 @@ import type { NewsletterStatsQuery, NewsletterStatsResponse, } from "@/app/api/user/stats/newsletters/route"; -import { useExpanded } from "@/app/(app)/stats/useExpanded"; -import { getDateRangeParams } from "@/app/(app)/stats/params"; -import { NewsletterModal } from "@/app/(app)/stats/NewsletterModal"; -import { useEmailsToIncludeFilter } from "@/app/(app)/stats/EmailsToIncludeFilter"; -import { DetailedStatsFilter } from "@/app/(app)/stats/DetailedStatsFilter"; +import { useExpanded } from "@/app/(app)/[account]/stats/useExpanded"; +import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; +import { NewsletterModal } from "@/app/(app)/[account]/stats/NewsletterModal"; +import { useEmailsToIncludeFilter } from "@/app/(app)/[account]/stats/EmailsToIncludeFilter"; +import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; import { usePremium } from "@/components/PremiumAlert"; import { useNewsletterFilter, useBulkUnsubscribeShortcuts, -} from "@/app/(app)/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; import { useStatLoader } from "@/providers/StatLoaderProvider"; -import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; import { useLabels } from "@/hooks/useLabels"; import { BulkUnsubscribeMobile, BulkUnsubscribeRowMobile, -} from "@/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile"; +} from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile"; import { BulkUnsubscribeDesktop, BulkUnsubscribeRowDesktop, -} from "@/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop"; +} from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop"; import { Card } from "@/components/ui/card"; -import { ShortcutTooltip } from "@/app/(app)/bulk-unsubscribe/ShortcutTooltip"; -import { SearchBar } from "@/app/(app)/bulk-unsubscribe/SearchBar"; +import { ShortcutTooltip } from "@/app/(app)/[account]/bulk-unsubscribe/ShortcutTooltip"; +import { SearchBar } from "@/app/(app)/[account]/bulk-unsubscribe/SearchBar"; import { useToggleSelect } from "@/hooks/useToggleSelect"; -import { BulkActions } from "@/app/(app)/bulk-unsubscribe/BulkActions"; -import { ArchiveProgress } from "@/app/(app)/bulk-unsubscribe/ArchiveProgress"; +import { BulkActions } from "@/app/(app)/[account]/bulk-unsubscribe/BulkActions"; +import { ArchiveProgress } from "@/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress"; import { ClientOnly } from "@/components/ClientOnly"; import { Toggle } from "@/components/Toggle"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSummary.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSummary.tsx similarity index 100% rename from apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSummary.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSummary.tsx diff --git a/apps/web/app/(app)/bulk-unsubscribe/SearchBar.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/SearchBar.tsx similarity index 100% rename from apps/web/app/(app)/bulk-unsubscribe/SearchBar.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/SearchBar.tsx diff --git a/apps/web/app/(app)/bulk-unsubscribe/ShortcutTooltip.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/ShortcutTooltip.tsx similarity index 100% rename from apps/web/app/(app)/bulk-unsubscribe/ShortcutTooltip.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/ShortcutTooltip.tsx diff --git a/apps/web/app/(app)/bulk-unsubscribe/common.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/common.tsx similarity index 99% rename from apps/web/app/(app)/bulk-unsubscribe/common.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/common.tsx index 3720cdd2f..b017a1355 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/common.tsx +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/common.tsx @@ -41,14 +41,14 @@ import { NewsletterStatus } from "@prisma/client"; import { toastError, toastSuccess } from "@/components/Toast"; import { createFilterAction } from "@/utils/actions/mail"; import { getGmailSearchUrl } from "@/utils/url"; -import type { Row } from "@/app/(app)/bulk-unsubscribe/types"; +import type { Row } from "@/app/(app)/[account]/bulk-unsubscribe/types"; import { useUnsubscribe, useAutoArchive, useApproveButton, useArchiveAll, useDeleteAllFromSender, -} from "@/app/(app)/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; import { LabelsSubMenu } from "@/components/LabelsSubMenu"; import type { UserLabel } from "@/hooks/useLabels"; diff --git a/apps/web/app/(app)/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/[account]/bulk-unsubscribe/hooks.ts similarity index 99% rename from apps/web/app/(app)/bulk-unsubscribe/hooks.ts rename to apps/web/app/(app)/[account]/bulk-unsubscribe/hooks.ts index 3a0348343..da9d031f4 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/hooks.ts +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/hooks.ts @@ -11,7 +11,7 @@ import { cleanUnsubscribeLink } from "@/utils/parse/parseHtml.client"; import { captureException } from "@/utils/error"; import { addToArchiveSenderQueue } from "@/store/archive-sender-queue"; import { deleteEmails } from "@/store/archive-queue"; -import type { Row } from "@/app/(app)/bulk-unsubscribe/types"; +import type { Row } from "@/app/(app)/[account]/bulk-unsubscribe/types"; import type { GetThreadsResponse } from "@/app/api/google/threads/basic/route"; import { isDefined } from "@/utils/types"; diff --git a/apps/web/app/(app)/bulk-unsubscribe/page.tsx b/apps/web/app/(app)/[account]/bulk-unsubscribe/page.tsx similarity index 81% rename from apps/web/app/(app)/bulk-unsubscribe/page.tsx rename to apps/web/app/(app)/[account]/bulk-unsubscribe/page.tsx index 0eaa14ab6..573596e9b 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/page.tsx +++ b/apps/web/app/(app)/[account]/bulk-unsubscribe/page.tsx @@ -1,4 +1,4 @@ -import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; import { BulkUnsubscribe } from "./BulkUnsubscribe"; import { checkAndRedirectForUpgrade } from "@/utils/premium/check-and-redirect-for-upgrade"; diff --git a/apps/web/app/(app)/bulk-unsubscribe/types.ts b/apps/web/app/(app)/[account]/bulk-unsubscribe/types.ts similarity index 100% rename from apps/web/app/(app)/bulk-unsubscribe/types.ts rename to apps/web/app/(app)/[account]/bulk-unsubscribe/types.ts diff --git a/apps/web/app/(app)/clean/ActionSelectionStep.tsx b/apps/web/app/(app)/[account]/clean/ActionSelectionStep.tsx similarity index 94% rename from apps/web/app/(app)/clean/ActionSelectionStep.tsx rename to apps/web/app/(app)/[account]/clean/ActionSelectionStep.tsx index 5149cf786..a230ae860 100644 --- a/apps/web/app/(app)/clean/ActionSelectionStep.tsx +++ b/apps/web/app/(app)/[account]/clean/ActionSelectionStep.tsx @@ -3,7 +3,7 @@ import { useCallback } from "react"; import { parseAsStringEnum, useQueryState } from "nuqs"; import { TypographyH3 } from "@/components/Typography"; -import { useStep } from "@/app/(app)/clean/useStep"; +import { useStep } from "@/app/(app)/[account]/clean/useStep"; import { ButtonListSurvey } from "@/components/ButtonListSurvey"; import { CleanAction } from "@prisma/client"; diff --git a/apps/web/app/(app)/clean/CleanHistory.tsx b/apps/web/app/(app)/[account]/clean/CleanHistory.tsx similarity index 100% rename from apps/web/app/(app)/clean/CleanHistory.tsx rename to apps/web/app/(app)/[account]/clean/CleanHistory.tsx diff --git a/apps/web/app/(app)/clean/CleanInstructionsStep.tsx b/apps/web/app/(app)/[account]/clean/CleanInstructionsStep.tsx similarity index 95% rename from apps/web/app/(app)/clean/CleanInstructionsStep.tsx rename to apps/web/app/(app)/[account]/clean/CleanInstructionsStep.tsx index c2381c473..a26ca0264 100644 --- a/apps/web/app/(app)/clean/CleanInstructionsStep.tsx +++ b/apps/web/app/(app)/[account]/clean/CleanInstructionsStep.tsx @@ -8,9 +8,9 @@ import { z } from "zod"; import { Button } from "@/components/ui/button"; import { TypographyH3 } from "@/components/Typography"; import { Input } from "@/components/Input"; -import { useStep } from "@/app/(app)/clean/useStep"; +import { useStep } from "@/app/(app)/[account]/clean/useStep"; import { Toggle } from "@/components/Toggle"; -import { useSkipSettings } from "@/app/(app)/clean/useSkipSettings"; +import { useSkipSettings } from "@/app/(app)/[account]/clean/useSkipSettings"; const schema = z.object({ instructions: z.string().optional() }); diff --git a/apps/web/app/(app)/clean/CleanRun.tsx b/apps/web/app/(app)/[account]/clean/CleanRun.tsx similarity index 84% rename from apps/web/app/(app)/clean/CleanRun.tsx rename to apps/web/app/(app)/[account]/clean/CleanRun.tsx index 7ed640d1b..422920eb2 100644 --- a/apps/web/app/(app)/clean/CleanRun.tsx +++ b/apps/web/app/(app)/[account]/clean/CleanRun.tsx @@ -1,5 +1,5 @@ -import { EmailFirehose } from "@/app/(app)/clean/EmailFirehose"; -import { PreviewBatch } from "@/app/(app)/clean/PreviewBatch"; +import { EmailFirehose } from "@/app/(app)/[account]/clean/EmailFirehose"; +import { PreviewBatch } from "@/app/(app)/[account]/clean/PreviewBatch"; import { Card } from "@/components/ui/card"; import type { getThreadsByJobId } from "@/utils/redis/clean"; import type { CleanupJob } from "@prisma/client"; diff --git a/apps/web/app/(app)/clean/CleanStats.tsx b/apps/web/app/(app)/[account]/clean/CleanStats.tsx similarity index 100% rename from apps/web/app/(app)/clean/CleanStats.tsx rename to apps/web/app/(app)/[account]/clean/CleanStats.tsx diff --git a/apps/web/app/(app)/clean/ConfirmationStep.tsx b/apps/web/app/(app)/[account]/clean/ConfirmationStep.tsx similarity index 98% rename from apps/web/app/(app)/clean/ConfirmationStep.tsx rename to apps/web/app/(app)/[account]/clean/ConfirmationStep.tsx index 2f32eec5a..9dc323813 100644 --- a/apps/web/app/(app)/clean/ConfirmationStep.tsx +++ b/apps/web/app/(app)/[account]/clean/ConfirmationStep.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/Badge"; import { cleanInboxAction } from "@/utils/actions/clean"; import { toastError } from "@/components/Toast"; import { CleanAction } from "@prisma/client"; -import { PREVIEW_RUN_COUNT } from "@/app/(app)/clean/consts"; +import { PREVIEW_RUN_COUNT } from "@/app/(app)/[account]/clean/consts"; import { HistoryIcon, SettingsIcon } from "lucide-react"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/clean/EmailFirehose.tsx b/apps/web/app/(app)/[account]/clean/EmailFirehose.tsx similarity index 100% rename from apps/web/app/(app)/clean/EmailFirehose.tsx rename to apps/web/app/(app)/[account]/clean/EmailFirehose.tsx diff --git a/apps/web/app/(app)/clean/EmailFirehoseItem.tsx b/apps/web/app/(app)/[account]/clean/EmailFirehoseItem.tsx similarity index 100% rename from apps/web/app/(app)/clean/EmailFirehoseItem.tsx rename to apps/web/app/(app)/[account]/clean/EmailFirehoseItem.tsx diff --git a/apps/web/app/(app)/clean/IntroStep.tsx b/apps/web/app/(app)/[account]/clean/IntroStep.tsx similarity index 96% rename from apps/web/app/(app)/clean/IntroStep.tsx rename to apps/web/app/(app)/[account]/clean/IntroStep.tsx index 705aaa49f..85fc75561 100644 --- a/apps/web/app/(app)/clean/IntroStep.tsx +++ b/apps/web/app/(app)/[account]/clean/IntroStep.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import { SectionDescription } from "@/components/Typography"; import { TypographyH3 } from "@/components/Typography"; import { Button } from "@/components/ui/button"; -import { useStep } from "@/app/(app)/clean/useStep"; +import { useStep } from "@/app/(app)/[account]/clean/useStep"; import { CleanAction } from "@prisma/client"; export function IntroStep({ diff --git a/apps/web/app/(app)/clean/PreviewBatch.tsx b/apps/web/app/(app)/[account]/clean/PreviewBatch.tsx similarity index 97% rename from apps/web/app/(app)/clean/PreviewBatch.tsx rename to apps/web/app/(app)/[account]/clean/PreviewBatch.tsx index b3e4f6ecf..adfbe4ae4 100644 --- a/apps/web/app/(app)/clean/PreviewBatch.tsx +++ b/apps/web/app/(app)/[account]/clean/PreviewBatch.tsx @@ -13,7 +13,7 @@ import { } from "@/components/ui/card"; import { cleanInboxAction } from "@/utils/actions/clean"; import { CleanAction, type CleanupJob } from "@prisma/client"; -import { PREVIEW_RUN_COUNT } from "@/app/(app)/clean/consts"; +import { PREVIEW_RUN_COUNT } from "@/app/(app)/[account]/clean/consts"; import { useAccount } from "@/providers/AccountProvider"; export function PreviewBatch({ job }: { job: CleanupJob }) { diff --git a/apps/web/app/(app)/clean/TimeRangeStep.tsx b/apps/web/app/(app)/[account]/clean/TimeRangeStep.tsx similarity index 86% rename from apps/web/app/(app)/clean/TimeRangeStep.tsx rename to apps/web/app/(app)/[account]/clean/TimeRangeStep.tsx index c54ae131c..c5ad5a69e 100644 --- a/apps/web/app/(app)/clean/TimeRangeStep.tsx +++ b/apps/web/app/(app)/[account]/clean/TimeRangeStep.tsx @@ -3,8 +3,8 @@ import { useCallback } from "react"; import { parseAsInteger, useQueryState } from "nuqs"; import { TypographyH3 } from "@/components/Typography"; -import { timeRangeOptions } from "@/app/(app)/clean/types"; -import { useStep } from "@/app/(app)/clean/useStep"; +import { timeRangeOptions } from "@/app/(app)/[account]/clean/types"; +import { useStep } from "@/app/(app)/[account]/clean/useStep"; import { ButtonListSurvey } from "@/components/ButtonListSurvey"; export function TimeRangeStep() { diff --git a/apps/web/app/(app)/clean/consts.ts b/apps/web/app/(app)/[account]/clean/consts.ts similarity index 100% rename from apps/web/app/(app)/clean/consts.ts rename to apps/web/app/(app)/[account]/clean/consts.ts diff --git a/apps/web/app/(app)/clean/helpers.ts b/apps/web/app/(app)/[account]/clean/helpers.ts similarity index 100% rename from apps/web/app/(app)/clean/helpers.ts rename to apps/web/app/(app)/[account]/clean/helpers.ts diff --git a/apps/web/app/(app)/clean/history/page.tsx b/apps/web/app/(app)/[account]/clean/history/page.tsx similarity index 91% rename from apps/web/app/(app)/clean/history/page.tsx rename to apps/web/app/(app)/[account]/clean/history/page.tsx index 35743da39..1ac58b97f 100644 --- a/apps/web/app/(app)/clean/history/page.tsx +++ b/apps/web/app/(app)/[account]/clean/history/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { PlusIcon } from "lucide-react"; -import { CleanHistory } from "@/app/(app)/clean/CleanHistory"; +import { CleanHistory } from "@/app/(app)/[account]/clean/CleanHistory"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { PageHeading } from "@/components/Typography"; import { Button } from "@/components/ui/button"; diff --git a/apps/web/app/(app)/clean/loading.tsx b/apps/web/app/(app)/[account]/clean/loading.tsx similarity index 100% rename from apps/web/app/(app)/clean/loading.tsx rename to apps/web/app/(app)/[account]/clean/loading.tsx diff --git a/apps/web/app/(app)/clean/onboarding/page.tsx b/apps/web/app/(app)/[account]/clean/onboarding/page.tsx similarity index 83% rename from apps/web/app/(app)/clean/onboarding/page.tsx rename to apps/web/app/(app)/[account]/clean/onboarding/page.tsx index a713224e7..099187595 100644 --- a/apps/web/app/(app)/clean/onboarding/page.tsx +++ b/apps/web/app/(app)/[account]/clean/onboarding/page.tsx @@ -1,13 +1,13 @@ import { Card } from "@/components/ui/card"; -import { IntroStep } from "@/app/(app)/clean/IntroStep"; -import { ActionSelectionStep } from "@/app/(app)/clean/ActionSelectionStep"; -import { CleanInstructionsStep } from "@/app/(app)/clean/CleanInstructionsStep"; -import { TimeRangeStep } from "@/app/(app)/clean/TimeRangeStep"; -import { ConfirmationStep } from "@/app/(app)/clean/ConfirmationStep"; +import { IntroStep } from "@/app/(app)/[account]/clean/IntroStep"; +import { ActionSelectionStep } from "@/app/(app)/[account]/clean/ActionSelectionStep"; +import { CleanInstructionsStep } from "@/app/(app)/[account]/clean/CleanInstructionsStep"; +import { TimeRangeStep } from "@/app/(app)/[account]/clean/TimeRangeStep"; +import { ConfirmationStep } from "@/app/(app)/[account]/clean/ConfirmationStep"; import { getGmailClient } from "@/utils/gmail/client"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getUnhandledCount } from "@/utils/assess"; -import { CleanStep } from "@/app/(app)/clean/types"; +import { CleanStep } from "@/app/(app)/[account]/clean/types"; import { CleanAction } from "@prisma/client"; export default async function CleanPage(props: { diff --git a/apps/web/app/(app)/clean/page.tsx b/apps/web/app/(app)/[account]/clean/page.tsx similarity index 86% rename from apps/web/app/(app)/clean/page.tsx rename to apps/web/app/(app)/[account]/clean/page.tsx index 73259c075..c13b38720 100644 --- a/apps/web/app/(app)/clean/page.tsx +++ b/apps/web/app/(app)/[account]/clean/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; -import { getLastJob } from "@/app/(app)/clean/helpers"; +import { getLastJob } from "@/app/(app)/[account]/clean/helpers"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { ConfirmationStep } from "@/app/(app)/clean/ConfirmationStep"; +import { ConfirmationStep } from "@/app/(app)/[account]/clean/ConfirmationStep"; import { Card } from "@/components/ui/card"; export default async function CleanPage() { diff --git a/apps/web/app/(app)/clean/run/page.tsx b/apps/web/app/(app)/[account]/clean/run/page.tsx similarity index 89% rename from apps/web/app/(app)/clean/run/page.tsx rename to apps/web/app/(app)/[account]/clean/run/page.tsx index 671e489e1..12989e236 100644 --- a/apps/web/app/(app)/clean/run/page.tsx +++ b/apps/web/app/(app)/[account]/clean/run/page.tsx @@ -2,8 +2,8 @@ import { getThreadsByJobId } from "@/utils/redis/clean"; import prisma from "@/utils/prisma"; import { CardTitle } from "@/components/ui/card"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getJobById, getLastJob } from "@/app/(app)/clean/helpers"; -import { CleanRun } from "@/app/(app)/clean/CleanRun"; +import { getJobById, getLastJob } from "@/app/(app)/[account]/clean/helpers"; +import { CleanRun } from "@/app/(app)/[account]/clean/CleanRun"; export default async function CleanRunPage(props: { searchParams: Promise<{ jobId: string; isPreviewBatch: string }>; diff --git a/apps/web/app/(app)/clean/types.ts b/apps/web/app/(app)/[account]/clean/types.ts similarity index 100% rename from apps/web/app/(app)/clean/types.ts rename to apps/web/app/(app)/[account]/clean/types.ts diff --git a/apps/web/app/(app)/clean/useEmailStream.ts b/apps/web/app/(app)/[account]/clean/useEmailStream.ts similarity index 100% rename from apps/web/app/(app)/clean/useEmailStream.ts rename to apps/web/app/(app)/[account]/clean/useEmailStream.ts diff --git a/apps/web/app/(app)/clean/useSkipSettings.ts b/apps/web/app/(app)/[account]/clean/useSkipSettings.ts similarity index 100% rename from apps/web/app/(app)/clean/useSkipSettings.ts rename to apps/web/app/(app)/[account]/clean/useSkipSettings.ts diff --git a/apps/web/app/(app)/clean/useStep.tsx b/apps/web/app/(app)/[account]/clean/useStep.tsx similarity index 87% rename from apps/web/app/(app)/clean/useStep.tsx rename to apps/web/app/(app)/[account]/clean/useStep.tsx index 7a654c8f2..cc8efaa66 100644 --- a/apps/web/app/(app)/clean/useStep.tsx +++ b/apps/web/app/(app)/[account]/clean/useStep.tsx @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { parseAsInteger, useQueryState } from "nuqs"; -import { CleanStep } from "@/app/(app)/clean/types"; +import { CleanStep } from "@/app/(app)/[account]/clean/types"; export function useStep() { const [step, setStep] = useQueryState( diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailList.tsx similarity index 98% rename from apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx rename to apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailList.tsx index d0e92e9cb..9c8c55ba0 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailList.tsx @@ -14,7 +14,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { DateCell } from "@/app/(app)/automation/ExecutedRulesTable"; +import { DateCell } from "@/app/(app)/[account]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { AlertBasic } from "@/components/Alert"; import { Button } from "@/components/ui/button"; diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailPromptForm.tsx similarity index 100% rename from apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx rename to apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailPromptForm.tsx diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailRejected.tsx b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailRejected.tsx similarity index 97% rename from apps/web/app/(app)/cold-email-blocker/ColdEmailRejected.tsx rename to apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailRejected.tsx index ea13bfe9e..ae1820632 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailRejected.tsx +++ b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailRejected.tsx @@ -11,7 +11,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { DateCell } from "@/app/(app)/automation/ExecutedRulesTable"; +import { DateCell } from "@/app/(app)/[account]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { AlertBasic } from "@/components/Alert"; import { useSearchParams } from "next/navigation"; diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailSettings.tsx similarity index 97% rename from apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx rename to apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailSettings.tsx index 374c7e825..61b785801 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx +++ b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailSettings.tsx @@ -12,7 +12,7 @@ import { updateColdEmailSettingsBody, } from "@/utils/actions/cold-email.validation"; import { updateColdEmailSettingsAction } from "@/utils/actions/cold-email"; -import { ColdEmailPromptForm } from "@/app/(app)/cold-email-blocker/ColdEmailPromptForm"; +import { ColdEmailPromptForm } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailPromptForm"; import { RadioGroup } from "@/components/RadioGroup"; import { useUser } from "@/hooks/useUser"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailTest.tsx b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailTest.tsx similarity index 83% rename from apps/web/app/(app)/cold-email-blocker/ColdEmailTest.tsx rename to apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailTest.tsx index ebe2d6706..9571abdf0 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailTest.tsx +++ b/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailTest.tsx @@ -4,7 +4,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { TestRulesContent } from "@/app/(app)/cold-email-blocker/TestRules"; +import { TestRulesContent } from "@/app/(app)/[account]/cold-email-blocker/TestRules"; export function ColdEmailTest() { return ( diff --git a/apps/web/app/(app)/cold-email-blocker/TestRules.tsx b/apps/web/app/(app)/[account]/cold-email-blocker/TestRules.tsx similarity index 100% rename from apps/web/app/(app)/cold-email-blocker/TestRules.tsx rename to apps/web/app/(app)/[account]/cold-email-blocker/TestRules.tsx diff --git a/apps/web/app/(app)/cold-email-blocker/page.tsx b/apps/web/app/(app)/[account]/cold-email-blocker/page.tsx similarity index 79% rename from apps/web/app/(app)/cold-email-blocker/page.tsx rename to apps/web/app/(app)/[account]/cold-email-blocker/page.tsx index a1de27e9b..6ef4accfb 100644 --- a/apps/web/app/(app)/cold-email-blocker/page.tsx +++ b/apps/web/app/(app)/[account]/cold-email-blocker/page.tsx @@ -1,12 +1,12 @@ import { Suspense } from "react"; -import { ColdEmailList } from "@/app/(app)/cold-email-blocker/ColdEmailList"; -import { ColdEmailSettings } from "@/app/(app)/cold-email-blocker/ColdEmailSettings"; +import { ColdEmailList } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailList"; +import { ColdEmailSettings } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailSettings"; import { Card } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; -import { ColdEmailRejected } from "@/app/(app)/cold-email-blocker/ColdEmailRejected"; -import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; -import { ColdEmailTest } from "@/app/(app)/cold-email-blocker/ColdEmailTest"; +import { ColdEmailRejected } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailRejected"; +import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; +import { ColdEmailTest } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailTest"; import { TabsToolbar } from "@/components/TabsToolbar"; import { GmailProvider } from "@/providers/GmailProvider"; diff --git a/apps/web/app/(app)/compose/ComposeEmailForm.tsx b/apps/web/app/(app)/[account]/compose/ComposeEmailForm.tsx similarity index 100% rename from apps/web/app/(app)/compose/ComposeEmailForm.tsx rename to apps/web/app/(app)/[account]/compose/ComposeEmailForm.tsx diff --git a/apps/web/app/(app)/compose/ComposeEmailFormLazy.tsx b/apps/web/app/(app)/[account]/compose/ComposeEmailFormLazy.tsx similarity index 100% rename from apps/web/app/(app)/compose/ComposeEmailFormLazy.tsx rename to apps/web/app/(app)/[account]/compose/ComposeEmailFormLazy.tsx diff --git a/apps/web/app/(app)/compose/page.tsx b/apps/web/app/(app)/[account]/compose/page.tsx similarity index 75% rename from apps/web/app/(app)/compose/page.tsx rename to apps/web/app/(app)/[account]/compose/page.tsx index a065bfd2c..6ac3c9720 100644 --- a/apps/web/app/(app)/compose/page.tsx +++ b/apps/web/app/(app)/[account]/compose/page.tsx @@ -1,4 +1,4 @@ -import { ComposeEmailFormLazy } from "@/app/(app)/compose/ComposeEmailFormLazy"; +import { ComposeEmailFormLazy } from "@/app/(app)/[account]/compose/ComposeEmailFormLazy"; import { TopSection } from "@/components/TopSection"; export default function ComposePage() { diff --git a/apps/web/app/(app)/debug/drafts/page.tsx b/apps/web/app/(app)/[account]/debug/drafts/page.tsx similarity index 100% rename from apps/web/app/(app)/debug/drafts/page.tsx rename to apps/web/app/(app)/[account]/debug/drafts/page.tsx diff --git a/apps/web/app/(app)/debug/learned/page.tsx b/apps/web/app/(app)/[account]/debug/learned/page.tsx similarity index 94% rename from apps/web/app/(app)/debug/learned/page.tsx rename to apps/web/app/(app)/[account]/debug/learned/page.tsx index 63b7dbb3f..e8c57f6f0 100644 --- a/apps/web/app/(app)/debug/learned/page.tsx +++ b/apps/web/app/(app)/[account]/debug/learned/page.tsx @@ -3,7 +3,7 @@ import useSWR from "swr"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PageHeading, TypographyP } from "@/components/Typography"; -import { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; +import { ViewGroup } from "@/app/(app)/[account]/automation/group/ViewGroup"; import type { GroupsResponse } from "@/app/api/user/group/route"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/debug/page.tsx b/apps/web/app/(app)/[account]/debug/page.tsx similarity index 100% rename from apps/web/app/(app)/debug/page.tsx rename to apps/web/app/(app)/[account]/debug/page.tsx diff --git a/apps/web/app/(app)/early-access/EarlyAccessFeatures.tsx b/apps/web/app/(app)/[account]/early-access/EarlyAccessFeatures.tsx similarity index 100% rename from apps/web/app/(app)/early-access/EarlyAccessFeatures.tsx rename to apps/web/app/(app)/[account]/early-access/EarlyAccessFeatures.tsx diff --git a/apps/web/app/(app)/early-access/page.tsx b/apps/web/app/(app)/[account]/early-access/page.tsx similarity index 96% rename from apps/web/app/(app)/early-access/page.tsx rename to apps/web/app/(app)/[account]/early-access/page.tsx index 74732b6d9..e01611574 100644 --- a/apps/web/app/(app)/early-access/page.tsx +++ b/apps/web/app/(app)/[account]/early-access/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { EarlyAccessFeatures } from "@/app/(app)/early-access/EarlyAccessFeatures"; +import { EarlyAccessFeatures } from "@/app/(app)/[account]/early-access/EarlyAccessFeatures"; import { Button } from "@/components/ui/button"; import { Card, diff --git a/apps/web/app/(app)/mail/BetaBanner.tsx b/apps/web/app/(app)/[account]/mail/BetaBanner.tsx similarity index 100% rename from apps/web/app/(app)/mail/BetaBanner.tsx rename to apps/web/app/(app)/[account]/mail/BetaBanner.tsx diff --git a/apps/web/app/(app)/mail/page.tsx b/apps/web/app/(app)/[account]/mail/page.tsx similarity index 95% rename from apps/web/app/(app)/mail/page.tsx rename to apps/web/app/(app)/[account]/mail/page.tsx index 4c97968bf..436d0237a 100644 --- a/apps/web/app/(app)/mail/page.tsx +++ b/apps/web/app/(app)/[account]/mail/page.tsx @@ -8,9 +8,9 @@ import { LoadingContent } from "@/components/LoadingContent"; import type { ThreadsQuery } from "@/app/api/google/threads/validation"; import type { ThreadsResponse } from "@/app/api/google/threads/controller"; import { refetchEmailListAtom } from "@/store/email"; -import { BetaBanner } from "@/app/(app)/mail/BetaBanner"; +import { BetaBanner } from "@/app/(app)/[account]/mail/BetaBanner"; import { ClientOnly } from "@/components/ClientOnly"; -import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; export default function Mail(props: { searchParams: Promise<{ type?: string; labelId?: string }>; diff --git a/apps/web/app/(app)/no-reply/page.tsx b/apps/web/app/(app)/[account]/no-reply/page.tsx similarity index 100% rename from apps/web/app/(app)/no-reply/page.tsx rename to apps/web/app/(app)/[account]/no-reply/page.tsx diff --git a/apps/web/app/(app)/permissions/consent/page.tsx b/apps/web/app/(app)/[account]/permissions/consent/page.tsx similarity index 100% rename from apps/web/app/(app)/permissions/consent/page.tsx rename to apps/web/app/(app)/[account]/permissions/consent/page.tsx diff --git a/apps/web/app/(app)/permissions/error/page.tsx b/apps/web/app/(app)/[account]/permissions/error/page.tsx similarity index 100% rename from apps/web/app/(app)/permissions/error/page.tsx rename to apps/web/app/(app)/[account]/permissions/error/page.tsx diff --git a/apps/web/app/(app)/premium/PremiumModal.tsx b/apps/web/app/(app)/[account]/premium/PremiumModal.tsx similarity index 90% rename from apps/web/app/(app)/premium/PremiumModal.tsx rename to apps/web/app/(app)/[account]/premium/PremiumModal.tsx index 25a16af99..c99c9d2d2 100644 --- a/apps/web/app/(app)/premium/PremiumModal.tsx +++ b/apps/web/app/(app)/[account]/premium/PremiumModal.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; export function usePremiumModal() { const [isOpen, setIsOpen] = useState(false); diff --git a/apps/web/app/(app)/premium/Pricing.tsx b/apps/web/app/(app)/[account]/premium/Pricing.tsx similarity index 99% rename from apps/web/app/(app)/premium/Pricing.tsx rename to apps/web/app/(app)/[account]/premium/Pricing.tsx index 5cee9a48c..afce1331a 100644 --- a/apps/web/app/(app)/premium/Pricing.tsx +++ b/apps/web/app/(app)/[account]/premium/Pricing.tsx @@ -20,7 +20,7 @@ import { enterpriseTier, frequencies, pricingAdditonalEmail, -} from "@/app/(app)/premium/config"; +} from "@/app/(app)/[account]/premium/config"; import { AlertWithButton } from "@/components/Alert"; import { switchPremiumPlanAction } from "@/utils/actions/premium"; import { TooltipExplanation } from "@/components/TooltipExplanation"; diff --git a/apps/web/app/(app)/premium/config.ts b/apps/web/app/(app)/[account]/premium/config.ts similarity index 100% rename from apps/web/app/(app)/premium/config.ts rename to apps/web/app/(app)/[account]/premium/config.ts diff --git a/apps/web/app/(app)/premium/page.tsx b/apps/web/app/(app)/[account]/premium/page.tsx similarity index 74% rename from apps/web/app/(app)/premium/page.tsx rename to apps/web/app/(app)/[account]/premium/page.tsx index a54528d5a..93116db9a 100644 --- a/apps/web/app/(app)/premium/page.tsx +++ b/apps/web/app/(app)/[account]/premium/page.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; export default function Premium() { return ( diff --git a/apps/web/app/(app)/reply-zero/AwaitingReply.tsx b/apps/web/app/(app)/[account]/reply-zero/AwaitingReply.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/AwaitingReply.tsx rename to apps/web/app/(app)/[account]/reply-zero/AwaitingReply.tsx diff --git a/apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx b/apps/web/app/(app)/[account]/reply-zero/EnableReplyTracker.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/EnableReplyTracker.tsx rename to apps/web/app/(app)/[account]/reply-zero/EnableReplyTracker.tsx diff --git a/apps/web/app/(app)/reply-zero/NeedsAction.tsx b/apps/web/app/(app)/[account]/reply-zero/NeedsAction.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/NeedsAction.tsx rename to apps/web/app/(app)/[account]/reply-zero/NeedsAction.tsx diff --git a/apps/web/app/(app)/reply-zero/NeedsReply.tsx b/apps/web/app/(app)/[account]/reply-zero/NeedsReply.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/NeedsReply.tsx rename to apps/web/app/(app)/[account]/reply-zero/NeedsReply.tsx diff --git a/apps/web/app/(app)/reply-zero/ReplyTrackerEmails.tsx b/apps/web/app/(app)/[account]/reply-zero/ReplyTrackerEmails.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/ReplyTrackerEmails.tsx rename to apps/web/app/(app)/[account]/reply-zero/ReplyTrackerEmails.tsx diff --git a/apps/web/app/(app)/reply-zero/Resolved.tsx b/apps/web/app/(app)/[account]/reply-zero/Resolved.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/Resolved.tsx rename to apps/web/app/(app)/[account]/reply-zero/Resolved.tsx diff --git a/apps/web/app/(app)/reply-zero/TimeRangeFilter.tsx b/apps/web/app/(app)/[account]/reply-zero/TimeRangeFilter.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/TimeRangeFilter.tsx rename to apps/web/app/(app)/[account]/reply-zero/TimeRangeFilter.tsx diff --git a/apps/web/app/(app)/reply-zero/date-filter.ts b/apps/web/app/(app)/[account]/reply-zero/date-filter.ts similarity index 100% rename from apps/web/app/(app)/reply-zero/date-filter.ts rename to apps/web/app/(app)/[account]/reply-zero/date-filter.ts diff --git a/apps/web/app/(app)/reply-zero/fetch-trackers.ts b/apps/web/app/(app)/[account]/reply-zero/fetch-trackers.ts similarity index 100% rename from apps/web/app/(app)/reply-zero/fetch-trackers.ts rename to apps/web/app/(app)/[account]/reply-zero/fetch-trackers.ts diff --git a/apps/web/app/(app)/reply-zero/onboarding/page.tsx b/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx similarity index 87% rename from apps/web/app/(app)/reply-zero/onboarding/page.tsx rename to apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx index cfac9036d..bf984ca32 100644 --- a/apps/web/app/(app)/reply-zero/onboarding/page.tsx +++ b/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { EnableReplyTracker } from "@/app/(app)/reply-zero/EnableReplyTracker"; +import { EnableReplyTracker } from "@/app/(app)/[account]/reply-zero/EnableReplyTracker"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { ActionType } from "@prisma/client"; diff --git a/apps/web/app/(app)/reply-zero/page.tsx b/apps/web/app/(app)/[account]/reply-zero/page.tsx similarity index 100% rename from apps/web/app/(app)/reply-zero/page.tsx rename to apps/web/app/(app)/[account]/reply-zero/page.tsx diff --git a/apps/web/app/(app)/setup/page.tsx b/apps/web/app/(app)/[account]/setup/page.tsx similarity index 100% rename from apps/web/app/(app)/setup/page.tsx rename to apps/web/app/(app)/[account]/setup/page.tsx diff --git a/apps/web/app/(app)/simple/SimpleList.tsx b/apps/web/app/(app)/[account]/simple/SimpleList.tsx similarity index 96% rename from apps/web/app/(app)/simple/SimpleList.tsx rename to apps/web/app/(app)/[account]/simple/SimpleList.tsx index a7a875bf2..8cf265b08 100644 --- a/apps/web/app/(app)/simple/SimpleList.tsx +++ b/apps/web/app/(app)/[account]/simple/SimpleList.tsx @@ -18,12 +18,12 @@ import { extractNameFromEmail } from "@/utils/email"; import { Tooltip } from "@/components/Tooltip"; import type { ParsedMessage } from "@/utils/types"; import { archiveEmails } from "@/store/archive-queue"; -import { Summary } from "@/app/(app)/simple/Summary"; +import { Summary } from "@/app/(app)/[account]/simple/Summary"; import { getGmailUrl } from "@/utils/url"; import { getNextCategory, simpleEmailCategoriesArray, -} from "@/app/(app)/simple/categories"; +} from "@/app/(app)/[account]/simple/categories"; import { DropdownMenu, DropdownMenuTrigger, @@ -34,8 +34,8 @@ import { markImportantMessageAction, markSpamThreadAction, } from "@/utils/actions/mail"; -import { SimpleProgress } from "@/app/(app)/simple/SimpleProgress"; -import { useSimpleProgress } from "@/app/(app)/simple/SimpleProgressProvider"; +import { SimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgress"; +import { useSimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; import { findCtaLink, findUnsubscribeLink, @@ -43,7 +43,7 @@ import { isMarketingEmail, removeReplyFromTextPlain, } from "@/utils/parse/parseHtml.client"; -import { ViewMoreButton } from "@/app/(app)/simple/ViewMoreButton"; +import { ViewMoreButton } from "@/app/(app)/[account]/simple/ViewMoreButton"; import { HtmlEmail } from "@/components/email-list/EmailContents"; export function SimpleList(props: { diff --git a/apps/web/app/(app)/simple/SimpleModeOnboarding.tsx b/apps/web/app/(app)/[account]/simple/SimpleModeOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/simple/SimpleModeOnboarding.tsx rename to apps/web/app/(app)/[account]/simple/SimpleModeOnboarding.tsx diff --git a/apps/web/app/(app)/simple/SimpleProgress.tsx b/apps/web/app/(app)/[account]/simple/SimpleProgress.tsx similarity index 96% rename from apps/web/app/(app)/simple/SimpleProgress.tsx rename to apps/web/app/(app)/[account]/simple/SimpleProgress.tsx index 2662f135a..3150c2488 100644 --- a/apps/web/app/(app)/simple/SimpleProgress.tsx +++ b/apps/web/app/(app)/[account]/simple/SimpleProgress.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useSimpleProgress } from "@/app/(app)/simple/SimpleProgressProvider"; +import { useSimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; export function calculateTimePassed(endTime: Date, startTime: Date) { return Math.floor((endTime.getTime() - startTime.getTime()) / 1000); diff --git a/apps/web/app/(app)/simple/SimpleProgressProvider.tsx b/apps/web/app/(app)/[account]/simple/SimpleProgressProvider.tsx similarity index 100% rename from apps/web/app/(app)/simple/SimpleProgressProvider.tsx rename to apps/web/app/(app)/[account]/simple/SimpleProgressProvider.tsx diff --git a/apps/web/app/(app)/simple/Summary.tsx b/apps/web/app/(app)/[account]/simple/Summary.tsx similarity index 91% rename from apps/web/app/(app)/simple/Summary.tsx rename to apps/web/app/(app)/[account]/simple/Summary.tsx index 3bd39d004..7951bbd63 100644 --- a/apps/web/app/(app)/simple/Summary.tsx +++ b/apps/web/app/(app)/[account]/simple/Summary.tsx @@ -3,7 +3,7 @@ import { useCompletion } from "ai/react"; import { useEffect } from "react"; import { ButtonLoader } from "@/components/Loading"; -import { ViewMoreButton } from "@/app/(app)/simple/ViewMoreButton"; +import { ViewMoreButton } from "@/app/(app)/[account]/simple/ViewMoreButton"; export function Summary({ textHtml, diff --git a/apps/web/app/(app)/simple/ViewMoreButton.tsx b/apps/web/app/(app)/[account]/simple/ViewMoreButton.tsx similarity index 100% rename from apps/web/app/(app)/simple/ViewMoreButton.tsx rename to apps/web/app/(app)/[account]/simple/ViewMoreButton.tsx diff --git a/apps/web/app/(app)/simple/categories.ts b/apps/web/app/(app)/[account]/simple/categories.ts similarity index 100% rename from apps/web/app/(app)/simple/categories.ts rename to apps/web/app/(app)/[account]/simple/categories.ts diff --git a/apps/web/app/(app)/simple/completed/OpenMultipleGmailButton.tsx b/apps/web/app/(app)/[account]/simple/completed/OpenMultipleGmailButton.tsx similarity index 100% rename from apps/web/app/(app)/simple/completed/OpenMultipleGmailButton.tsx rename to apps/web/app/(app)/[account]/simple/completed/OpenMultipleGmailButton.tsx diff --git a/apps/web/app/(app)/simple/completed/ShareOnTwitterButton.tsx b/apps/web/app/(app)/[account]/simple/completed/ShareOnTwitterButton.tsx similarity index 84% rename from apps/web/app/(app)/simple/completed/ShareOnTwitterButton.tsx rename to apps/web/app/(app)/[account]/simple/completed/ShareOnTwitterButton.tsx index 7c953d283..40dd76897 100644 --- a/apps/web/app/(app)/simple/completed/ShareOnTwitterButton.tsx +++ b/apps/web/app/(app)/[account]/simple/completed/ShareOnTwitterButton.tsx @@ -3,11 +3,11 @@ import { ExternalLinkIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { useSimpleProgress } from "@/app/(app)/simple/SimpleProgressProvider"; +import { useSimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; import { calculateTimePassed, formatTime, -} from "@/app/(app)/simple/SimpleProgress"; +} from "@/app/(app)/[account]/simple/SimpleProgress"; export function ShareOnTwitterButton() { const { handled, startTime, endTime } = useSimpleProgress(); diff --git a/apps/web/app/(app)/simple/completed/page.tsx b/apps/web/app/(app)/[account]/simple/completed/page.tsx similarity index 86% rename from apps/web/app/(app)/simple/completed/page.tsx rename to apps/web/app/(app)/[account]/simple/completed/page.tsx index adc54a03d..03b50b31e 100644 --- a/apps/web/app/(app)/simple/completed/page.tsx +++ b/apps/web/app/(app)/[account]/simple/completed/page.tsx @@ -6,9 +6,9 @@ import { getThreads } from "@/app/api/google/threads/controller"; import { Button } from "@/components/ui/button"; import { getGmailBasicSearchUrl } from "@/utils/url"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { OpenMultipleGmailButton } from "@/app/(app)/simple/completed/OpenMultipleGmailButton"; -import { SimpleProgressCompleted } from "@/app/(app)/simple/SimpleProgress"; -import { ShareOnTwitterButton } from "@/app/(app)/simple/completed/ShareOnTwitterButton"; +import { OpenMultipleGmailButton } from "@/app/(app)/[account]/simple/completed/OpenMultipleGmailButton"; +import { SimpleProgressCompleted } from "@/app/(app)/[account]/simple/SimpleProgress"; +import { ShareOnTwitterButton } from "@/app/(app)/[account]/simple/completed/ShareOnTwitterButton"; export default async function SimpleCompletedPage() { const session = await auth(); diff --git a/apps/web/app/(app)/simple/layout.tsx b/apps/web/app/(app)/[account]/simple/layout.tsx similarity index 64% rename from apps/web/app/(app)/simple/layout.tsx rename to apps/web/app/(app)/[account]/simple/layout.tsx index ec688b151..664456f52 100644 --- a/apps/web/app/(app)/simple/layout.tsx +++ b/apps/web/app/(app)/[account]/simple/layout.tsx @@ -1,4 +1,4 @@ -import { SimpleEmailStateProvider } from "@/app/(app)/simple/SimpleProgressProvider"; +import { SimpleEmailStateProvider } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; export default async function SimpleLayout({ children, diff --git a/apps/web/app/(app)/simple/loading.tsx b/apps/web/app/(app)/[account]/simple/loading.tsx similarity index 100% rename from apps/web/app/(app)/simple/loading.tsx rename to apps/web/app/(app)/[account]/simple/loading.tsx diff --git a/apps/web/app/(app)/simple/page.tsx b/apps/web/app/(app)/[account]/simple/page.tsx similarity index 93% rename from apps/web/app/(app)/simple/page.tsx rename to apps/web/app/(app)/[account]/simple/page.tsx index 04014ee63..8e454eaea 100644 --- a/apps/web/app/(app)/simple/page.tsx +++ b/apps/web/app/(app)/[account]/simple/page.tsx @@ -1,15 +1,15 @@ import { redirect, RedirectType } from "next/navigation"; -import { SimpleList } from "@/app/(app)/simple/SimpleList"; +import { SimpleList } from "@/app/(app)/[account]/simple/SimpleList"; import { getNextCategory, simpleEmailCategories, simpleEmailCategoriesArray, -} from "@/app/(app)/simple/categories"; +} from "@/app/(app)/[account]/simple/categories"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { PageHeading } from "@/components/Typography"; import { getGmailClient } from "@/utils/gmail/client"; import { parseMessage } from "@/utils/mail"; -import { SimpleModeOnboarding } from "@/app/(app)/simple/SimpleModeOnboarding"; +import { SimpleModeOnboarding } from "@/app/(app)/[account]/simple/SimpleModeOnboarding"; import { ClientOnly } from "@/components/ClientOnly"; import { getMessage, getMessages } from "@/utils/gmail/message"; diff --git a/apps/web/app/(app)/smart-categories/CategorizeProgress.tsx b/apps/web/app/(app)/[account]/smart-categories/CategorizeProgress.tsx similarity index 100% rename from apps/web/app/(app)/smart-categories/CategorizeProgress.tsx rename to apps/web/app/(app)/[account]/smart-categories/CategorizeProgress.tsx diff --git a/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/[account]/smart-categories/CategorizeWithAiButton.tsx similarity index 94% rename from apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx rename to apps/web/app/(app)/[account]/smart-categories/CategorizeWithAiButton.tsx index 0ab64bce2..8ba993007 100644 --- a/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/[account]/smart-categories/CategorizeWithAiButton.tsx @@ -6,9 +6,9 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { bulkCategorizeSendersAction } from "@/utils/actions/categorize"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; -import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; import type { ButtonProps } from "@/components/ui/button"; -import { useCategorizeProgress } from "@/app/(app)/smart-categories/CategorizeProgress"; +import { useCategorizeProgress } from "@/app/(app)/[account]/smart-categories/CategorizeProgress"; import { Tooltip } from "@/components/Tooltip"; import { useAccount } from "@/providers/AccountProvider"; diff --git a/apps/web/app/(app)/smart-categories/CreateCategoryButton.tsx b/apps/web/app/(app)/[account]/smart-categories/CreateCategoryButton.tsx similarity index 100% rename from apps/web/app/(app)/smart-categories/CreateCategoryButton.tsx rename to apps/web/app/(app)/[account]/smart-categories/CreateCategoryButton.tsx diff --git a/apps/web/app/(app)/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/[account]/smart-categories/Uncategorized.tsx similarity index 98% rename from apps/web/app/(app)/smart-categories/Uncategorized.tsx rename to apps/web/app/(app)/[account]/smart-categories/Uncategorized.tsx index ca50a9efd..cf679d810 100644 --- a/apps/web/app/(app)/smart-categories/Uncategorized.tsx +++ b/apps/web/app/(app)/[account]/smart-categories/Uncategorized.tsx @@ -18,7 +18,7 @@ import { import { SectionDescription } from "@/components/Typography"; import { ButtonLoader } from "@/components/Loading"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; -import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; import { Toggle } from "@/components/Toggle"; import { setAutoCategorizeAction } from "@/utils/actions/categorize"; import { TooltipExplanation } from "@/components/TooltipExplanation"; diff --git a/apps/web/app/(app)/smart-categories/page.tsx b/apps/web/app/(app)/[account]/smart-categories/page.tsx similarity index 88% rename from apps/web/app/(app)/smart-categories/page.tsx rename to apps/web/app/(app)/[account]/smart-categories/page.tsx index 518184e06..ca4e3a1ae 100644 --- a/apps/web/app/(app)/smart-categories/page.tsx +++ b/apps/web/app/(app)/[account]/smart-categories/page.tsx @@ -8,9 +8,9 @@ import prisma from "@/utils/prisma"; import { ClientOnly } from "@/components/ClientOnly"; import { GroupedTable } from "@/components/GroupedTable"; import { TopBar } from "@/components/TopBar"; -import { CreateCategoryButton } from "@/app/(app)/smart-categories/CreateCategoryButton"; +import { CreateCategoryButton } from "@/app/(app)/[account]/smart-categories/CreateCategoryButton"; import { getUserCategoriesWithRules } from "@/utils/category.server"; -import { CategorizeWithAiButton } from "@/app/(app)/smart-categories/CategorizeWithAiButton"; +import { CategorizeWithAiButton } from "@/app/(app)/[account]/smart-categories/CategorizeWithAiButton"; import { Card, CardContent, @@ -19,12 +19,12 @@ import { CardDescription, } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Uncategorized } from "@/app/(app)/smart-categories/Uncategorized"; -import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; -import { ArchiveProgress } from "@/app/(app)/bulk-unsubscribe/ArchiveProgress"; +import { Uncategorized } from "@/app/(app)/[account]/smart-categories/Uncategorized"; +import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; +import { ArchiveProgress } from "@/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; import { Button } from "@/components/ui/button"; -import { CategorizeSendersProgress } from "@/app/(app)/smart-categories/CategorizeProgress"; +import { CategorizeSendersProgress } from "@/app/(app)/[account]/smart-categories/CategorizeProgress"; import { getCategorizationProgress } from "@/utils/redis/categorization-progress"; export const dynamic = "force-dynamic"; diff --git a/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx b/apps/web/app/(app)/[account]/smart-categories/setup/SetUpCategories.tsx similarity index 99% rename from apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx rename to apps/web/app/(app)/[account]/smart-categories/setup/SetUpCategories.tsx index f132f32ac..eb0f54f14 100644 --- a/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx +++ b/apps/web/app/(app)/[account]/smart-categories/setup/SetUpCategories.tsx @@ -23,7 +23,7 @@ import { cn } from "@/utils"; import { CreateCategoryButton, CreateCategoryDialog, -} from "@/app/(app)/smart-categories/CreateCategoryButton"; +} from "@/app/(app)/[account]/smart-categories/CreateCategoryButton"; import type { Category } from "@prisma/client"; type CardCategory = Pick & { diff --git a/apps/web/app/(app)/smart-categories/setup/SmartCategoriesOnboarding.tsx b/apps/web/app/(app)/[account]/smart-categories/setup/SmartCategoriesOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/smart-categories/setup/SmartCategoriesOnboarding.tsx rename to apps/web/app/(app)/[account]/smart-categories/setup/SmartCategoriesOnboarding.tsx diff --git a/apps/web/app/(app)/smart-categories/setup/page.tsx b/apps/web/app/(app)/[account]/smart-categories/setup/page.tsx similarity index 73% rename from apps/web/app/(app)/smart-categories/setup/page.tsx rename to apps/web/app/(app)/[account]/smart-categories/setup/page.tsx index 929b0cad7..9f679af4f 100644 --- a/apps/web/app/(app)/smart-categories/setup/page.tsx +++ b/apps/web/app/(app)/[account]/smart-categories/setup/page.tsx @@ -1,5 +1,5 @@ -import { SetUpCategories } from "@/app/(app)/smart-categories/setup/SetUpCategories"; -import { SmartCategoriesOnboarding } from "@/app/(app)/smart-categories/setup/SmartCategoriesOnboarding"; +import { SetUpCategories } from "@/app/(app)/[account]/smart-categories/setup/SetUpCategories"; +import { SmartCategoriesOnboarding } from "@/app/(app)/[account]/smart-categories/setup/SmartCategoriesOnboarding"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { ClientOnly } from "@/components/ClientOnly"; import { getUserCategories } from "@/utils/category.server"; diff --git a/apps/web/app/(app)/stats/ActionBar.tsx b/apps/web/app/(app)/[account]/stats/ActionBar.tsx similarity index 96% rename from apps/web/app/(app)/stats/ActionBar.tsx rename to apps/web/app/(app)/[account]/stats/ActionBar.tsx index 475ce172a..383004424 100644 --- a/apps/web/app/(app)/stats/ActionBar.tsx +++ b/apps/web/app/(app)/[account]/stats/ActionBar.tsx @@ -2,7 +2,7 @@ import React from "react"; import subDays from "date-fns/subDays"; import { GanttChartIcon, Tally3Icon } from "lucide-react"; import type { DateRange } from "react-day-picker"; -import { DetailedStatsFilter } from "@/app/(app)/stats/DetailedStatsFilter"; +import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; import { DatePickerWithRange } from "@/components/DatePickerWithRange"; export function ActionBar({ diff --git a/apps/web/app/(app)/stats/CombinedStatsChart.tsx b/apps/web/app/(app)/[account]/stats/CombinedStatsChart.tsx similarity index 100% rename from apps/web/app/(app)/stats/CombinedStatsChart.tsx rename to apps/web/app/(app)/[account]/stats/CombinedStatsChart.tsx diff --git a/apps/web/app/(app)/stats/DetailedStats.tsx b/apps/web/app/(app)/[account]/stats/DetailedStats.tsx similarity index 97% rename from apps/web/app/(app)/stats/DetailedStats.tsx rename to apps/web/app/(app)/[account]/stats/DetailedStats.tsx index d640adb0e..5bf3e45b5 100644 --- a/apps/web/app/(app)/stats/DetailedStats.tsx +++ b/apps/web/app/(app)/[account]/stats/DetailedStats.tsx @@ -12,8 +12,8 @@ import type { StatsByWeekResponse, StatsByWeekParams, } from "@/app/api/user/stats/tinybird/route"; -import { DetailedStatsFilter } from "@/app/(app)/stats/DetailedStatsFilter"; -import { getDateRangeParams } from "@/app/(app)/stats/params"; +import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; +import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; export function DetailedStats(props: { dateRange?: DateRange | undefined; diff --git a/apps/web/app/(app)/stats/DetailedStatsFilter.tsx b/apps/web/app/(app)/[account]/stats/DetailedStatsFilter.tsx similarity index 100% rename from apps/web/app/(app)/stats/DetailedStatsFilter.tsx rename to apps/web/app/(app)/[account]/stats/DetailedStatsFilter.tsx diff --git a/apps/web/app/(app)/stats/EmailActionsAnalytics.tsx b/apps/web/app/(app)/[account]/stats/EmailActionsAnalytics.tsx similarity index 100% rename from apps/web/app/(app)/stats/EmailActionsAnalytics.tsx rename to apps/web/app/(app)/[account]/stats/EmailActionsAnalytics.tsx diff --git a/apps/web/app/(app)/stats/EmailAnalytics.tsx b/apps/web/app/(app)/[account]/stats/EmailAnalytics.tsx similarity index 95% rename from apps/web/app/(app)/stats/EmailAnalytics.tsx rename to apps/web/app/(app)/[account]/stats/EmailAnalytics.tsx index 155dd2d62..93e1f24b2 100644 --- a/apps/web/app/(app)/stats/EmailAnalytics.tsx +++ b/apps/web/app/(app)/[account]/stats/EmailAnalytics.tsx @@ -2,13 +2,13 @@ import useSWR from "swr"; import type { DateRange } from "react-day-picker"; -import { useExpanded } from "@/app/(app)/stats/useExpanded"; +import { useExpanded } from "@/app/(app)/[account]/stats/useExpanded"; import type { RecipientsResponse } from "@/app/api/user/stats/recipients/route"; import type { SendersResponse } from "@/app/api/user/stats/senders/route"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; import { BarList } from "@/components/charts/BarList"; -import { getDateRangeParams } from "@/app/(app)/stats/params"; +import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; import { getGmailSearchUrl } from "@/utils/url"; import { useAccount } from "@/providers/AccountProvider"; export function EmailAnalytics(props: { diff --git a/apps/web/app/(app)/stats/EmailsToIncludeFilter.tsx b/apps/web/app/(app)/[account]/stats/EmailsToIncludeFilter.tsx similarity index 95% rename from apps/web/app/(app)/stats/EmailsToIncludeFilter.tsx rename to apps/web/app/(app)/[account]/stats/EmailsToIncludeFilter.tsx index 242e322c9..bd36788e5 100644 --- a/apps/web/app/(app)/stats/EmailsToIncludeFilter.tsx +++ b/apps/web/app/(app)/[account]/stats/EmailsToIncludeFilter.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { FilterIcon } from "lucide-react"; -import { DetailedStatsFilter } from "@/app/(app)/stats/DetailedStatsFilter"; +import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; export function useEmailsToIncludeFilter() { const [types, setTypes] = useState< diff --git a/apps/web/app/(app)/stats/LoadProgress.tsx b/apps/web/app/(app)/[account]/stats/LoadProgress.tsx similarity index 100% rename from apps/web/app/(app)/stats/LoadProgress.tsx rename to apps/web/app/(app)/[account]/stats/LoadProgress.tsx diff --git a/apps/web/app/(app)/stats/LoadStatsButton.tsx b/apps/web/app/(app)/[account]/stats/LoadStatsButton.tsx similarity index 100% rename from apps/web/app/(app)/stats/LoadStatsButton.tsx rename to apps/web/app/(app)/[account]/stats/LoadStatsButton.tsx diff --git a/apps/web/app/(app)/stats/NewsletterModal.tsx b/apps/web/app/(app)/[account]/stats/NewsletterModal.tsx similarity index 96% rename from apps/web/app/(app)/stats/NewsletterModal.tsx rename to apps/web/app/(app)/[account]/stats/NewsletterModal.tsx index bc22538c5..4500c9365 100644 --- a/apps/web/app/(app)/stats/NewsletterModal.tsx +++ b/apps/web/app/(app)/[account]/stats/NewsletterModal.tsx @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { getDateRangeParams } from "@/app/(app)/stats/params"; +import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; import type { SenderEmailsQuery, SenderEmailsResponse, @@ -24,9 +24,9 @@ import { Button } from "@/components/ui/button"; import { getGmailFilterSettingsUrl } from "@/utils/url"; import { Tooltip } from "@/components/Tooltip"; import { AlertBasic } from "@/components/Alert"; -import { MoreDropdown } from "@/app/(app)/bulk-unsubscribe/common"; +import { MoreDropdown } from "@/app/(app)/[account]/bulk-unsubscribe/common"; import { useLabels } from "@/hooks/useLabels"; -import type { Row } from "@/app/(app)/bulk-unsubscribe/types"; +import type { Row } from "@/app/(app)/[account]/bulk-unsubscribe/types"; import { useThreads } from "@/hooks/useThreads"; import { useAccount } from "@/providers/AccountProvider"; import { onAutoArchive } from "@/utils/actions/client"; diff --git a/apps/web/app/(app)/stats/Stats.tsx b/apps/web/app/(app)/[account]/stats/Stats.tsx similarity index 81% rename from apps/web/app/(app)/stats/Stats.tsx rename to apps/web/app/(app)/[account]/stats/Stats.tsx index d780360d1..09e82ef31 100644 --- a/apps/web/app/(app)/stats/Stats.tsx +++ b/apps/web/app/(app)/[account]/stats/Stats.tsx @@ -3,16 +3,16 @@ import { useState, useMemo, useCallback, useEffect } from "react"; import type { DateRange } from "react-day-picker"; import subDays from "date-fns/subDays"; -import { DetailedStats } from "@/app/(app)/stats/DetailedStats"; -import { LoadStatsButton } from "@/app/(app)/stats/LoadStatsButton"; -import { EmailAnalytics } from "@/app/(app)/stats/EmailAnalytics"; -import { StatsSummary } from "@/app/(app)/stats/StatsSummary"; -import { StatsOnboarding } from "@/app/(app)/stats/StatsOnboarding"; -import { ActionBar } from "@/app/(app)/stats/ActionBar"; -import { LoadProgress } from "@/app/(app)/stats/LoadProgress"; +import { DetailedStats } from "@/app/(app)/[account]/stats/DetailedStats"; +import { LoadStatsButton } from "@/app/(app)/[account]/stats/LoadStatsButton"; +import { EmailAnalytics } from "@/app/(app)/[account]/stats/EmailAnalytics"; +import { StatsSummary } from "@/app/(app)/[account]/stats/StatsSummary"; +import { StatsOnboarding } from "@/app/(app)/[account]/stats/StatsOnboarding"; +import { ActionBar } from "@/app/(app)/[account]/stats/ActionBar"; +import { LoadProgress } from "@/app/(app)/[account]/stats/LoadProgress"; import { useStatLoader } from "@/providers/StatLoaderProvider"; -import { EmailActionsAnalytics } from "@/app/(app)/stats/EmailActionsAnalytics"; -import { BulkUnsubscribeSummary } from "@/app/(app)/bulk-unsubscribe/BulkUnsubscribeSummary"; +import { EmailActionsAnalytics } from "@/app/(app)/[account]/stats/EmailActionsAnalytics"; +import { BulkUnsubscribeSummary } from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSummary"; import { CardBasic } from "@/components/ui/card"; import { Title } from "@tremor/react"; import { TopBar } from "@/components/TopBar"; diff --git a/apps/web/app/(app)/stats/StatsChart.tsx b/apps/web/app/(app)/[account]/stats/StatsChart.tsx similarity index 100% rename from apps/web/app/(app)/stats/StatsChart.tsx rename to apps/web/app/(app)/[account]/stats/StatsChart.tsx diff --git a/apps/web/app/(app)/stats/StatsOnboarding.tsx b/apps/web/app/(app)/[account]/stats/StatsOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/stats/StatsOnboarding.tsx rename to apps/web/app/(app)/[account]/stats/StatsOnboarding.tsx diff --git a/apps/web/app/(app)/stats/StatsSummary.tsx b/apps/web/app/(app)/[account]/stats/StatsSummary.tsx similarity index 100% rename from apps/web/app/(app)/stats/StatsSummary.tsx rename to apps/web/app/(app)/[account]/stats/StatsSummary.tsx diff --git a/apps/web/app/(app)/stats/page.tsx b/apps/web/app/(app)/[account]/stats/page.tsx similarity index 79% rename from apps/web/app/(app)/stats/page.tsx rename to apps/web/app/(app)/[account]/stats/page.tsx index 1c5de3710..14075308a 100644 --- a/apps/web/app/(app)/stats/page.tsx +++ b/apps/web/app/(app)/[account]/stats/page.tsx @@ -1,4 +1,4 @@ -import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; import { Stats } from "./Stats"; import { checkAndRedirectForUpgrade } from "@/utils/premium/check-and-redirect-for-upgrade"; diff --git a/apps/web/app/(app)/stats/params.ts b/apps/web/app/(app)/[account]/stats/params.ts similarity index 100% rename from apps/web/app/(app)/stats/params.ts rename to apps/web/app/(app)/[account]/stats/params.ts diff --git a/apps/web/app/(app)/stats/useExpanded.tsx b/apps/web/app/(app)/[account]/stats/useExpanded.tsx similarity index 100% rename from apps/web/app/(app)/stats/useExpanded.tsx rename to apps/web/app/(app)/[account]/stats/useExpanded.tsx diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 8c1db43c8..59f0051aa 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -10,7 +10,7 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { PostHogIdentify } from "@/providers/PostHogProvider"; import { CommandK } from "@/components/CommandK"; import { AppProviders } from "@/providers/AppProviders"; -import { AssessUser } from "@/app/(app)/assess"; +import { AssessUser } from "@/app/(app)/[account]/assess"; import { LastLogin } from "@/app/(app)/last-login"; import { SentryIdentify } from "@/app/(app)/sentry-identify"; import { ErrorMessages } from "@/app/(app)/ErrorMessages"; diff --git a/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx b/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx index 15a2033d2..9fe5baba9 100644 --- a/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx @@ -22,7 +22,7 @@ import type { import { LoadingContent } from "@/components/LoadingContent"; import { ProgressBar } from "@tremor/react"; import { ONE_MONTH_MS } from "@/utils/date"; -import { useUnsubscribe } from "@/app/(app)/bulk-unsubscribe/hooks"; +import { useUnsubscribe } from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; import { NewsletterStatus } from "@prisma/client"; import { EmailCell } from "@/components/EmailCell"; diff --git a/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx b/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx index de2db235a..9d35aa0dd 100644 --- a/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { ColdEmailForm } from "@/app/(app)/cold-email-blocker/ColdEmailSettings"; +import { ColdEmailForm } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailSettings"; import { useUser } from "@/hooks/useUser"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx b/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx index eea564e7d..88ebe7053 100644 --- a/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx @@ -29,7 +29,7 @@ import { rulesExamplesBody, type RulesExamplesBody, } from "@/utils/actions/rule.validation"; -import { examplePrompts } from "@/app/(app)/automation/examples"; +import { examplePrompts } from "@/app/(app)/[account]/automation/examples"; import { useAccount } from "@/providers/AccountProvider"; type RulesExamplesResponse = InferSafeActionFnResult< diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 357f5439f..d1cd04168 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -4,7 +4,7 @@ import { OnboardingBulkUnsubscriber } from "@/app/(app)/onboarding/OnboardingBul import { OnboardingColdEmailBlocker } from "@/app/(app)/onboarding/OnboardingColdEmailBlocker"; import { OnboardingAIEmailAssistant } from "@/app/(app)/onboarding/OnboardingEmailAssistant"; import { OnboardingFinish } from "@/app/(app)/onboarding/OnboardingFinish"; -import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; import { LoadStats } from "@/providers/StatLoaderProvider"; export const maxDuration = 120; diff --git a/apps/web/app/(app)/settings/MultiAccountSection.tsx b/apps/web/app/(app)/settings/MultiAccountSection.tsx index 8e34458ed..3952a41f0 100644 --- a/apps/web/app/(app)/settings/MultiAccountSection.tsx +++ b/apps/web/app/(app)/settings/MultiAccountSection.tsx @@ -24,11 +24,11 @@ import { import type { MultiAccountEmailsResponse } from "@/app/api/user/settings/multi-account/route"; import { AlertBasic, AlertWithButton } from "@/components/Alert"; import { usePremium } from "@/components/PremiumAlert"; -import { pricingAdditonalEmail } from "@/app/(app)/premium/config"; +import { pricingAdditonalEmail } from "@/app/(app)/[account]/premium/config"; import { PremiumTier } from "@prisma/client"; import { env } from "@/env"; import { getUserTier, isAdminForPremium } from "@/utils/premium"; -import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; import { useAction } from "next-safe-action/hooks"; import { toastError, toastSuccess } from "@/components/Toast"; diff --git a/apps/web/app/(landing)/ai-automation/page.tsx b/apps/web/app/(landing)/ai-automation/page.tsx index 185874818..884fb0c9f 100644 --- a/apps/web/app/(landing)/ai-automation/page.tsx +++ b/apps/web/app/(landing)/ai-automation/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { FeaturesAiAssistant } from "@/app/(landing)/home/Features"; diff --git a/apps/web/app/(landing)/block-cold-emails/page.tsx b/apps/web/app/(landing)/block-cold-emails/page.tsx index 2ddd74554..04adca4de 100644 --- a/apps/web/app/(landing)/block-cold-emails/page.tsx +++ b/apps/web/app/(landing)/block-cold-emails/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesColdEmailBlocker } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx b/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx index 6e161daa1..a7a08a052 100644 --- a/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx +++ b/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesUnsubscribe } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/email-analytics/page.tsx b/apps/web/app/(landing)/email-analytics/page.tsx index e8a50cd30..d1eda3061 100644 --- a/apps/web/app/(landing)/email-analytics/page.tsx +++ b/apps/web/app/(landing)/email-analytics/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { FeaturesStats } from "@/app/(landing)/home/Features"; diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx index 4f2ec7bb2..4e444472d 100644 --- a/apps/web/app/(landing)/page.tsx +++ b/apps/web/app/(landing)/page.tsx @@ -4,7 +4,7 @@ import { HeroHome } from "@/app/(landing)/home/Hero"; import { FeaturesHome } from "@/app/(landing)/home/Features"; import { Privacy } from "@/app/(landing)/home/Privacy"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/reply-zero-ai/page.tsx b/apps/web/app/(landing)/reply-zero-ai/page.tsx index 5a1eface1..fa49622d9 100644 --- a/apps/web/app/(landing)/reply-zero-ai/page.tsx +++ b/apps/web/app/(landing)/reply-zero-ai/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesReplyZero } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/welcome-upgrade/page.tsx b/apps/web/app/(landing)/welcome-upgrade/page.tsx index 2e0f2793c..bb82f41ca 100644 --- a/apps/web/app/(landing)/welcome-upgrade/page.tsx +++ b/apps/web/app/(landing)/welcome-upgrade/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import { CheckCircleIcon } from "lucide-react"; -import { Pricing } from "@/app/(app)/premium/Pricing"; +import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; import { Footer } from "@/app/(landing)/home/Footer"; import { Loading } from "@/components/Loading"; import { WelcomeUpgradeNav } from "@/app/(landing)/welcome-upgrade/WelcomeUpgradeNav"; diff --git a/apps/web/app/api/lemon-squeezy/webhook/route.ts b/apps/web/app/api/lemon-squeezy/webhook/route.ts index 4454640f8..9430f5877 100644 --- a/apps/web/app/api/lemon-squeezy/webhook/route.ts +++ b/apps/web/app/api/lemon-squeezy/webhook/route.ts @@ -18,7 +18,7 @@ import { upgradedToPremium, } from "@inboxzero/loops"; import { SafeError } from "@/utils/error"; -import { getSubscriptionTier } from "@/app/(app)/premium/config"; +import { getSubscriptionTier } from "@/app/(app)/[account]/premium/config"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("Lemon Squeezy Webhook"); diff --git a/apps/web/app/api/user/group/[groupId]/messages/controller.ts b/apps/web/app/api/user/group/[groupId]/messages/controller.ts index d2d8c970c..5e1d56b67 100644 --- a/apps/web/app/api/user/group/[groupId]/messages/controller.ts +++ b/apps/web/app/api/user/group/[groupId]/messages/controller.ts @@ -7,7 +7,7 @@ import { findMatchingGroupItem } from "@/utils/group/find-matching-group"; import { parseMessage } from "@/utils/mail"; import { extractEmailAddress } from "@/utils/email"; import { type GroupItem, GroupItemType } from "@prisma/client"; -import type { MessageWithGroupItem } from "@/app/(app)/automation/rule/[ruleId]/examples/types"; +import type { MessageWithGroupItem } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/types"; import { SafeError } from "@/utils/error"; const PAGE_SIZE = 20; diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts index 691951c70..732ef832d 100644 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ b/apps/web/app/api/user/rules/[id]/example/controller.ts @@ -4,7 +4,7 @@ import { getMessage, getMessages } from "@/utils/gmail/message"; import type { MessageWithGroupItem, RuleWithGroup, -} from "@/app/(app)/automation/rule/[ruleId]/examples/types"; +} from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/types"; import { matchesStaticRule } from "@/utils/ai/choose-rule/match-rules"; import { fetchPaginatedMessages } from "@/app/api/user/group/[groupId]/messages/controller"; import { diff --git a/apps/web/app/api/v1/reply-tracker/route.ts b/apps/web/app/api/v1/reply-tracker/route.ts index 527d8a0a0..ac1c8f270 100644 --- a/apps/web/app/api/v1/reply-tracker/route.ts +++ b/apps/web/app/api/v1/reply-tracker/route.ts @@ -7,7 +7,7 @@ import { } from "./validation"; import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth"; import { ThreadTrackerType } from "@prisma/client"; -import { getPaginatedThreadTrackers } from "@/app/(app)/reply-zero/fetch-trackers"; +import { getPaginatedThreadTrackers } from "@/app/(app)/[account]/reply-zero/fetch-trackers"; import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; import { isDefined } from "@/utils/types"; import { getEmailAccountId } from "@/app/api/v1/helpers"; diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 2552f8d4e..1e4aa81f6 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -24,6 +24,7 @@ import { useAccounts } from "@/hooks/useAccounts"; import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; import { useModifierKey } from "@/hooks/useModifierKey"; import { useAccount } from "@/providers/AccountProvider"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; export function AccountSwitcher() { const { data: accountsData } = useAccounts(); @@ -39,9 +40,26 @@ export function AccountSwitcherInternal({ const { isMobile } = useSidebar(); const { symbol: modifierSymbol } = useModifierKey(); - const { account: activeAccount, isLoading, setAccountId } = useAccount(); + const { account: activeAccount, isLoading } = useAccount(); - useAccountHotkeys(accounts, setAccountId); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const getHref = useCallback( + (accountId: string) => { + if (!activeAccount?.accountId) return `/${accountId}`; + + const basePath = pathname.split("?")[0] || "/"; + const newBasePath = basePath.replace(activeAccount.accountId, accountId); + + const tab = searchParams.get("tab"); + + return `${newBasePath}${tab ? `?tab=${tab}` : ""}`; + }, + [pathname, activeAccount?.accountId, searchParams], + ); + + useAccountHotkeys(accounts, getHref); if (isLoading) return null; if (!activeAccount) return null; @@ -76,18 +94,16 @@ export function AccountSwitcherInternal({ Accounts {accounts.map((account, index) => ( - setAccountId(account.accountId)} - className="gap-2 p-2" - > - - {account.user.name} - - {modifierSymbol} - {index + 1} - - + + + + {account.user.name} + + {modifierSymbol} + {index + 1} + + + ))} @@ -126,8 +142,9 @@ function ProfileImage({ function useAccountHotkeys( accounts: GetAccountsResponse["accounts"], - setAccountId: (accountId: string) => void, + getHref: (accountId: string) => string, ) { + const router = useRouter(); const { isMac } = useModifierKey(); const modifierKey = isMac ? "meta" : "ctrl"; @@ -148,12 +165,12 @@ function useAccountHotkeys( ) { const accountIndex = pressedDigit - 1; if (accounts[accountIndex]) { - setAccountId(accounts[accountIndex].accountId); + router.push(getHref(accounts[accountIndex].accountId)); event.preventDefault(); // Prevent browser default behavior } } }, - [accounts, setAccountId], + [accounts, getHref, router], ); useHotkeys( diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 60f2781fb..0e56d5e25 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -47,7 +47,7 @@ import { } from "@/store/archive-sender-queue"; import { getGmailSearchUrl, getGmailUrl } from "@/utils/url"; import { MessageText } from "@/components/Typography"; -import { CreateCategoryDialog } from "@/app/(app)/smart-categories/CreateCategoryButton"; +import { CreateCategoryDialog } from "@/app/(app)/[account]/smart-categories/CreateCategoryButton"; import { DropdownMenu, DropdownMenuContent, diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index b3f527c7f..496ddee9a 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -11,10 +11,10 @@ import { isPremium, } from "@/utils/premium"; import { Tooltip } from "@/components/Tooltip"; -import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; import { PremiumTier } from "@prisma/client"; import { useUser } from "@/hooks/useUser"; -import { businessTierName } from "@/app/(app)/premium/config"; +import { businessTierName } from "@/app/(app)/[account]/premium/config"; export function usePremium() { const swrResponse = useUser(); diff --git a/apps/web/components/email-list/EmailMessage.tsx b/apps/web/components/email-list/EmailMessage.tsx index 9ae3109bd..7ac9639bf 100644 --- a/apps/web/components/email-list/EmailMessage.tsx +++ b/apps/web/components/email-list/EmailMessage.tsx @@ -8,13 +8,13 @@ import { import { Tooltip } from "@/components/Tooltip"; import { extractNameFromEmail } from "@/utils/email"; import { formatShortDate } from "@/utils/date"; -import { ComposeEmailFormLazy } from "@/app/(app)/compose/ComposeEmailFormLazy"; +import { ComposeEmailFormLazy } from "@/app/(app)/[account]/compose/ComposeEmailFormLazy"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import type { ParsedMessage } from "@/utils/types"; import { forwardEmailHtml, forwardEmailSubject } from "@/utils/gmail/forward"; import { extractEmailReply } from "@/utils/parse/extract-reply.client"; -import type { ReplyingToEmail } from "@/app/(app)/compose/ComposeEmailForm"; +import type { ReplyingToEmail } from "@/app/(app)/[account]/compose/ComposeEmailForm"; import { createReplyContent } from "@/utils/gmail/reply"; import { cn } from "@/utils"; import { generateNudgeReplyAction } from "@/utils/actions/generate-reply"; diff --git a/apps/web/providers/AccountProvider.tsx b/apps/web/providers/AccountProvider.tsx index fb8bfa01f..1a68b34f9 100644 --- a/apps/web/providers/AccountProvider.tsx +++ b/apps/web/providers/AccountProvider.tsx @@ -1,25 +1,25 @@ "use client"; -import { createContext, useContext, useEffect, useState } from "react"; -import { useQueryState } from "nuqs"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; type Account = GetAccountsResponse["accounts"][number]; -type AccountContext = { +type Context = { account: Account | undefined; email: string; isLoading: boolean; - setAccountId: (newId: string) => Promise; }; -const AccountContext = createContext(undefined); +const AccountContext = createContext(undefined); export function AccountProvider({ children }: { children: React.ReactNode }) { + const params = useParams<{ account: string | undefined }>(); + // TODO: throw an error if account is not defined? + const accountId = params.account; const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [accountId, setAccountId] = useQueryState("accountId"); - const [account, setAccount] = useState(undefined); useEffect(() => { async function fetchAccounts() { @@ -41,19 +41,19 @@ export function AccountProvider({ children }: { children: React.ReactNode }) { fetchAccounts(); }, []); - useEffect(() => { + const account = useMemo(() => { if (data?.accounts) { const currentAccount = data.accounts.find((acc) => acc.accountId === accountId) ?? data.accounts[0]; - setAccount(currentAccount); + return currentAccount; } }, [data, accountId]); return ( {children} diff --git a/apps/web/providers/ComposeModalProvider.tsx b/apps/web/providers/ComposeModalProvider.tsx index 3209eeaf1..b228fd92c 100644 --- a/apps/web/providers/ComposeModalProvider.tsx +++ b/apps/web/providers/ComposeModalProvider.tsx @@ -2,7 +2,7 @@ import { createContext, useContext } from "react"; import { useModal } from "@/hooks/useModal"; -import { ComposeEmailFormLazy } from "@/app/(app)/compose/ComposeEmailFormLazy"; +import { ComposeEmailFormLazy } from "@/app/(app)/[account]/compose/ComposeEmailFormLazy"; import { Dialog, DialogContent, diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index 3658c2f89..a62522ce7 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -15,7 +15,7 @@ import { } from "@/app/api/lemon-squeezy/api"; import { PremiumTier } from "@prisma/client"; import { ONE_MONTH_MS, ONE_YEAR_MS } from "@/utils/date"; -import { getVariantId } from "@/app/(app)/premium/config"; +import { getVariantId } from "@/app/(app)/[account]/premium/config"; import { actionClientUser, adminActionClient, diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 17b836031..0b991b26b 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -9,7 +9,7 @@ import { } from "@prisma/client"; import { getUserCategoriesForNames } from "@/utils/category.server"; import { getActionRiskLevel, type RiskAction } from "@/utils/risk"; -import { hasExampleParams } from "@/app/(app)/automation/examples"; +import { hasExampleParams } from "@/app/(app)/[account]/automation/examples"; const logger = createScopedLogger("rule"); From 91ec7f80d17c14a640f3cdef0c72e99f8f275db5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:04:38 +0300 Subject: [PATCH 064/176] move over more to email support --- .../(app)/[account]/clean/onboarding/page.tsx | 10 +++---- .../app/(app)/[account]/clean/run/page.tsx | 29 ++++++++++++------- .../{ => [account]}/settings/AboutSection.tsx | 2 +- .../settings/AboutSectionForm.tsx | 0 .../settings/ApiKeysCreateForm.tsx | 0 .../settings/ApiKeysSection.tsx | 2 +- .../settings/DeleteSection.tsx | 0 .../settings/EmailUpdatesSection.tsx | 0 .../settings/LabelsSection.tsx | 0 .../{ => [account]}/settings/ModelSection.tsx | 0 .../settings/MultiAccountSection.tsx | 0 .../settings/SignatureSectionForm.tsx | 0 .../settings/WebhookGenerate.tsx | 0 .../settings/WebhookSection.tsx | 2 +- .../(app)/{ => [account]}/settings/page.tsx | 29 +++++++++---------- .../app/(app)/[account]/simple/SimpleList.tsx | 5 ++-- apps/web/app/(app)/[account]/simple/page.tsx | 14 ++++----- apps/web/app/(app)/[account]/usage/page.tsx | 29 +++++++++++++++++++ .../app/(app)/{ => [account]}/usage/usage.tsx | 0 apps/web/app/(app)/usage/page.tsx | 21 -------------- apps/web/app/api/google/labels/route.ts | 14 ++++----- apps/web/app/api/google/messages/route.ts | 23 ++++++++------- apps/web/utils/account.ts | 19 ++++++++++++ 23 files changed, 116 insertions(+), 83 deletions(-) rename apps/web/app/(app)/{ => [account]}/settings/AboutSection.tsx (60%) rename apps/web/app/(app)/{ => [account]}/settings/AboutSectionForm.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/ApiKeysCreateForm.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/ApiKeysSection.tsx (97%) rename apps/web/app/(app)/{ => [account]}/settings/DeleteSection.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/EmailUpdatesSection.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/LabelsSection.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/ModelSection.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/MultiAccountSection.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/SignatureSectionForm.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/WebhookGenerate.tsx (100%) rename apps/web/app/(app)/{ => [account]}/settings/WebhookSection.tsx (90%) rename apps/web/app/(app)/{ => [account]}/settings/page.tsx (53%) create mode 100644 apps/web/app/(app)/[account]/usage/page.tsx rename apps/web/app/(app)/{ => [account]}/usage/usage.tsx (100%) delete mode 100644 apps/web/app/(app)/usage/page.tsx diff --git a/apps/web/app/(app)/[account]/clean/onboarding/page.tsx b/apps/web/app/(app)/[account]/clean/onboarding/page.tsx index 099187595..54797080f 100644 --- a/apps/web/app/(app)/[account]/clean/onboarding/page.tsx +++ b/apps/web/app/(app)/[account]/clean/onboarding/page.tsx @@ -4,13 +4,13 @@ import { ActionSelectionStep } from "@/app/(app)/[account]/clean/ActionSelection import { CleanInstructionsStep } from "@/app/(app)/[account]/clean/CleanInstructionsStep"; import { TimeRangeStep } from "@/app/(app)/[account]/clean/TimeRangeStep"; import { ConfirmationStep } from "@/app/(app)/[account]/clean/ConfirmationStep"; -import { getGmailClient } from "@/utils/gmail/client"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getUnhandledCount } from "@/utils/assess"; import { CleanStep } from "@/app/(app)/[account]/clean/types"; import { CleanAction } from "@prisma/client"; +import { getGmailClientForAccountId } from "@/utils/account"; export default async function CleanPage(props: { + params: Promise<{ account: string }>; searchParams: Promise<{ step: string; action?: CleanAction; @@ -23,10 +23,10 @@ export default async function CleanPage(props: { skipAttachment?: string; }>; }) { - const session = await auth(); - if (!session?.user.email) return
Not authenticated
; + const params = await props.params; + const accountId = params.account; - const gmail = getGmailClient(session); + const gmail = await getGmailClientForAccountId({ accountId }); const { unhandledCount } = await getUnhandledCount(gmail); const searchParams = await props.searchParams; diff --git a/apps/web/app/(app)/[account]/clean/run/page.tsx b/apps/web/app/(app)/[account]/clean/run/page.tsx index 12989e236..c53715d82 100644 --- a/apps/web/app/(app)/[account]/clean/run/page.tsx +++ b/apps/web/app/(app)/[account]/clean/run/page.tsx @@ -1,34 +1,43 @@ import { getThreadsByJobId } from "@/utils/redis/clean"; import prisma from "@/utils/prisma"; import { CardTitle } from "@/components/ui/card"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getJobById, getLastJob } from "@/app/(app)/[account]/clean/helpers"; import { CleanRun } from "@/app/(app)/[account]/clean/CleanRun"; export default async function CleanRunPage(props: { + params: Promise<{ account: string }>; searchParams: Promise<{ jobId: string; isPreviewBatch: string }>; }) { + const params = await props.params; + const accountId = params.account; + const searchParams = await props.searchParams; const { jobId, isPreviewBatch } = searchParams; - const session = await auth(); - if (!session?.user.email) return
Not authenticated
; + const emailAccount = await prisma.emailAccount.findUnique({ + where: { accountId }, + select: { email: true }, + }); + + if (!emailAccount) return Email account not found; - const userEmail = session.user.email; + const email = emailAccount.email; - const threads = await getThreadsByJobId({ emailAccountId: userEmail, jobId }); + const threads = await getThreadsByJobId({ emailAccountId: email, jobId }); const job = jobId - ? await getJobById({ email: userEmail, jobId }) - : await getLastJob({ email: userEmail }); + ? await getJobById({ email, jobId }) + : await getLastJob({ email }); if (!job) return Job not found; const [total, done] = await Promise.all([ - prisma.cleanupThread.count({ where: { jobId, emailAccountId: userEmail } }), prisma.cleanupThread.count({ - where: { jobId, emailAccountId: userEmail, archived: true }, + where: { jobId, emailAccountId: email }, + }), + prisma.cleanupThread.count({ + where: { jobId, emailAccountId: email, archived: true }, }), ]); @@ -39,7 +48,7 @@ export default async function CleanRunPage(props: { threads={threads} total={total} done={done} - userEmail={userEmail} + userEmail={email} /> ); } diff --git a/apps/web/app/(app)/settings/AboutSection.tsx b/apps/web/app/(app)/[account]/settings/AboutSection.tsx similarity index 60% rename from apps/web/app/(app)/settings/AboutSection.tsx rename to apps/web/app/(app)/[account]/settings/AboutSection.tsx index a0f38b6f6..495768561 100644 --- a/apps/web/app/(app)/settings/AboutSection.tsx +++ b/apps/web/app/(app)/[account]/settings/AboutSection.tsx @@ -1,4 +1,4 @@ -import { AboutSectionForm } from "@/app/(app)/settings/AboutSectionForm"; +import { AboutSectionForm } from "@/app/(app)/[account]/settings/AboutSectionForm"; export const AboutSection = async ({ about }: { about: string | null }) => { return ; diff --git a/apps/web/app/(app)/settings/AboutSectionForm.tsx b/apps/web/app/(app)/[account]/settings/AboutSectionForm.tsx similarity index 100% rename from apps/web/app/(app)/settings/AboutSectionForm.tsx rename to apps/web/app/(app)/[account]/settings/AboutSectionForm.tsx diff --git a/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx b/apps/web/app/(app)/[account]/settings/ApiKeysCreateForm.tsx similarity index 100% rename from apps/web/app/(app)/settings/ApiKeysCreateForm.tsx rename to apps/web/app/(app)/[account]/settings/ApiKeysCreateForm.tsx diff --git a/apps/web/app/(app)/settings/ApiKeysSection.tsx b/apps/web/app/(app)/[account]/settings/ApiKeysSection.tsx similarity index 97% rename from apps/web/app/(app)/settings/ApiKeysSection.tsx rename to apps/web/app/(app)/[account]/settings/ApiKeysSection.tsx index c4ec523f4..6faebaee6 100644 --- a/apps/web/app/(app)/settings/ApiKeysSection.tsx +++ b/apps/web/app/(app)/[account]/settings/ApiKeysSection.tsx @@ -12,7 +12,7 @@ import { import { ApiKeysCreateButtonModal, ApiKeysDeactivateButton, -} from "@/app/(app)/settings/ApiKeysCreateForm"; +} from "@/app/(app)/[account]/settings/ApiKeysCreateForm"; import { Card } from "@/components/ui/card"; export async function ApiKeysSection() { diff --git a/apps/web/app/(app)/settings/DeleteSection.tsx b/apps/web/app/(app)/[account]/settings/DeleteSection.tsx similarity index 100% rename from apps/web/app/(app)/settings/DeleteSection.tsx rename to apps/web/app/(app)/[account]/settings/DeleteSection.tsx diff --git a/apps/web/app/(app)/settings/EmailUpdatesSection.tsx b/apps/web/app/(app)/[account]/settings/EmailUpdatesSection.tsx similarity index 100% rename from apps/web/app/(app)/settings/EmailUpdatesSection.tsx rename to apps/web/app/(app)/[account]/settings/EmailUpdatesSection.tsx diff --git a/apps/web/app/(app)/settings/LabelsSection.tsx b/apps/web/app/(app)/[account]/settings/LabelsSection.tsx similarity index 100% rename from apps/web/app/(app)/settings/LabelsSection.tsx rename to apps/web/app/(app)/[account]/settings/LabelsSection.tsx diff --git a/apps/web/app/(app)/settings/ModelSection.tsx b/apps/web/app/(app)/[account]/settings/ModelSection.tsx similarity index 100% rename from apps/web/app/(app)/settings/ModelSection.tsx rename to apps/web/app/(app)/[account]/settings/ModelSection.tsx diff --git a/apps/web/app/(app)/settings/MultiAccountSection.tsx b/apps/web/app/(app)/[account]/settings/MultiAccountSection.tsx similarity index 100% rename from apps/web/app/(app)/settings/MultiAccountSection.tsx rename to apps/web/app/(app)/[account]/settings/MultiAccountSection.tsx diff --git a/apps/web/app/(app)/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/[account]/settings/SignatureSectionForm.tsx similarity index 100% rename from apps/web/app/(app)/settings/SignatureSectionForm.tsx rename to apps/web/app/(app)/[account]/settings/SignatureSectionForm.tsx diff --git a/apps/web/app/(app)/settings/WebhookGenerate.tsx b/apps/web/app/(app)/[account]/settings/WebhookGenerate.tsx similarity index 100% rename from apps/web/app/(app)/settings/WebhookGenerate.tsx rename to apps/web/app/(app)/[account]/settings/WebhookGenerate.tsx diff --git a/apps/web/app/(app)/settings/WebhookSection.tsx b/apps/web/app/(app)/[account]/settings/WebhookSection.tsx similarity index 90% rename from apps/web/app/(app)/settings/WebhookSection.tsx rename to apps/web/app/(app)/[account]/settings/WebhookSection.tsx index 01b745ecd..830724efa 100644 --- a/apps/web/app/(app)/settings/WebhookSection.tsx +++ b/apps/web/app/(app)/[account]/settings/WebhookSection.tsx @@ -1,7 +1,7 @@ import { FormSection, FormSectionLeft } from "@/components/Form"; import { Card } from "@/components/ui/card"; import { CopyInput } from "@/components/CopyInput"; -import { RegenerateSecretButton } from "@/app/(app)/settings/WebhookGenerate"; +import { RegenerateSecretButton } from "@/app/(app)/[account]/settings/WebhookGenerate"; export async function WebhookSection({ webhookSecret, diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/[account]/settings/page.tsx similarity index 53% rename from apps/web/app/(app)/settings/page.tsx rename to apps/web/app/(app)/[account]/settings/page.tsx index 5e8ca4fe5..3f5ac2a21 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/[account]/settings/page.tsx @@ -1,24 +1,23 @@ import { FormWrapper } from "@/components/Form"; -import { AboutSection } from "@/app/(app)/settings/AboutSection"; +import { AboutSection } from "@/app/(app)/[account]/settings/AboutSection"; // import { SignatureSectionForm } from "@/app/(app)/settings/SignatureSectionForm"; // import { LabelsSection } from "@/app/(app)/settings/LabelsSection"; -import { DeleteSection } from "@/app/(app)/settings/DeleteSection"; -import { ModelSection } from "@/app/(app)/settings/ModelSection"; -import { EmailUpdatesSection } from "@/app/(app)/settings/EmailUpdatesSection"; -import { MultiAccountSection } from "@/app/(app)/settings/MultiAccountSection"; -import { ApiKeysSection } from "@/app/(app)/settings/ApiKeysSection"; -import { WebhookSection } from "@/app/(app)/settings/WebhookSection"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { DeleteSection } from "@/app/(app)/[account]/settings/DeleteSection"; +import { ModelSection } from "@/app/(app)/[account]/settings/ModelSection"; +import { EmailUpdatesSection } from "@/app/(app)/[account]/settings/EmailUpdatesSection"; +import { MultiAccountSection } from "@/app/(app)/[account]/settings/MultiAccountSection"; +import { ApiKeysSection } from "@/app/(app)/[account]/settings/ApiKeysSection"; +import { WebhookSection } from "@/app/(app)/[account]/settings/WebhookSection"; import prisma from "@/utils/prisma"; -import { NotLoggedIn } from "@/components/ErrorDisplay"; -export default async function SettingsPage() { - const session = await auth(); - - if (!session?.user.email) return ; +export default async function SettingsPage(props: { + params: Promise<{ account: string }>; +}) { + const params = await props.params; + const accountId = params.account; const user = await prisma.emailAccount.findUnique({ - where: { email: session.user.email }, + where: { accountId }, select: { about: true, signature: true, @@ -27,7 +26,7 @@ export default async function SettingsPage() { }, }); - if (!user) return ; + if (!user) return

Email account not found

; return ( diff --git a/apps/web/app/(app)/[account]/simple/SimpleList.tsx b/apps/web/app/(app)/[account]/simple/SimpleList.tsx index 8cf265b08..304887532 100644 --- a/apps/web/app/(app)/[account]/simple/SimpleList.tsx +++ b/apps/web/app/(app)/[account]/simple/SimpleList.tsx @@ -45,13 +45,14 @@ import { } from "@/utils/parse/parseHtml.client"; import { ViewMoreButton } from "@/app/(app)/[account]/simple/ViewMoreButton"; import { HtmlEmail } from "@/components/email-list/EmailContents"; +import { useAccount } from "@/providers/AccountProvider"; export function SimpleList(props: { messages: ParsedMessage[]; nextPageToken?: string | null; - userEmail: string; type: string; }) { + const { email } = useAccount(); const { toHandleLater, onSetHandled, onSetToHandleLater } = useSimpleProgress(); @@ -84,7 +85,7 @@ export function SimpleList(props: { handleUnsubscribe(message.id)} diff --git a/apps/web/app/(app)/[account]/simple/page.tsx b/apps/web/app/(app)/[account]/simple/page.tsx index 8e454eaea..a8d4cc0c4 100644 --- a/apps/web/app/(app)/[account]/simple/page.tsx +++ b/apps/web/app/(app)/[account]/simple/page.tsx @@ -5,28 +5,27 @@ import { simpleEmailCategories, simpleEmailCategoriesArray, } from "@/app/(app)/[account]/simple/categories"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { PageHeading } from "@/components/Typography"; -import { getGmailClient } from "@/utils/gmail/client"; import { parseMessage } from "@/utils/mail"; import { SimpleModeOnboarding } from "@/app/(app)/[account]/simple/SimpleModeOnboarding"; import { ClientOnly } from "@/components/ClientOnly"; import { getMessage, getMessages } from "@/utils/gmail/message"; +import { getGmailClientForAccountId } from "@/utils/account"; export const dynamic = "force-dynamic"; export default async function SimplePage(props: { + params: Promise<{ account: string }>; searchParams: Promise<{ pageToken?: string; type?: string }>; }) { + const params = await props.params; + const accountId = params.account; + const searchParams = await props.searchParams; const { pageToken, type = "IMPORTANT" } = searchParams; - const session = await auth(); - const email = session?.user.email; - if (!email) throw new Error("Not authenticated"); - - const gmail = getGmailClient(session); + const gmail = await getGmailClientForAccountId({ accountId }); const categoryTitle = simpleEmailCategories.get(type); @@ -68,7 +67,6 @@ export default async function SimplePage(props: { diff --git a/apps/web/app/(app)/[account]/usage/page.tsx b/apps/web/app/(app)/[account]/usage/page.tsx new file mode 100644 index 000000000..197769ca3 --- /dev/null +++ b/apps/web/app/(app)/[account]/usage/page.tsx @@ -0,0 +1,29 @@ +import { getUsage } from "@/utils/redis/usage"; +import { TopSection } from "@/components/TopSection"; +import { Usage } from "@/app/(app)/[account]/usage/usage"; +import prisma from "@/utils/prisma"; + +export default async function UsagePage(props: { + params: Promise<{ account: string }>; +}) { + const params = await props.params; + const accountId = params.account; + + const emailAccount = await prisma.emailAccount.findUnique({ + where: { accountId }, + select: { email: true }, + }); + + if (!emailAccount) return

Email account not found

; + + const usage = await getUsage({ email: emailAccount.email }); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/usage/usage.tsx b/apps/web/app/(app)/[account]/usage/usage.tsx similarity index 100% rename from apps/web/app/(app)/usage/usage.tsx rename to apps/web/app/(app)/[account]/usage/usage.tsx diff --git a/apps/web/app/(app)/usage/page.tsx b/apps/web/app/(app)/usage/page.tsx deleted file mode 100644 index 493c61ec2..000000000 --- a/apps/web/app/(app)/usage/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getUsage } from "@/utils/redis/usage"; -import { TopSection } from "@/components/TopSection"; -import { NotLoggedIn } from "@/components/ErrorDisplay"; -import { Usage } from "@/app/(app)/usage/usage"; - -export default async function UsagePage() { - const session = await auth(); - if (!session?.user.email) return ; - - const usage = await getUsage({ email: session.user.email }); - - return ( -
- -
- -
-
- ); -} diff --git a/apps/web/app/api/google/labels/route.ts b/apps/web/app/api/google/labels/route.ts index 2818a73f7..0735f93d9 100644 --- a/apps/web/app/api/google/labels/route.ts +++ b/apps/web/app/api/google/labels/route.ts @@ -1,9 +1,8 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getGmailClient } from "@/utils/gmail/client"; import { getLabels as getGmailLabels } from "@/utils/gmail/label"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; +import { getGmailClientForEmail } from "@/utils/account"; export const dynamic = "force-dynamic"; export const maxDuration = 30; @@ -17,12 +16,9 @@ async function getLabels(gmail: gmail_v1.Gmail) { return { labels }; } -export const GET = withError(async () => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const gmail = getGmailClient(session); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const gmail = await getGmailClientForEmail({ email }); const labels = await getLabels(gmail); return NextResponse.json(labels); diff --git a/apps/web/app/api/google/messages/route.ts b/apps/web/app/api/google/messages/route.ts index 221c6c8b3..76143a3ce 100644 --- a/apps/web/app/api/google/messages/route.ts +++ b/apps/web/app/api/google/messages/route.ts @@ -2,12 +2,13 @@ import { type NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { getGmailClient } from "@/utils/gmail/client"; import { queryBatchMessages } from "@/utils/gmail/message"; -import { withError } from "@/utils/middleware"; +import { withAuth, withError } from "@/utils/middleware"; import { SafeError } from "@/utils/error"; import { messageQuerySchema } from "@/app/api/google/messages/validation"; import { createScopedLogger } from "@/utils/logger"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; import { GmailLabel } from "@/utils/gmail/label"; +import { getGmailClientForEmail } from "@/utils/account"; const logger = createScopedLogger("api/google/messages"); @@ -16,15 +17,14 @@ export type MessagesResponse = Awaited>; async function getMessages({ query, pageToken, + email, }: { query?: string | null; pageToken?: string | null; + email: string; }) { - const session = await auth(); - if (!session?.user.email) throw new SafeError("Not authenticated"); - try { - const gmail = getGmailClient(session); + const gmail = await getGmailClientForEmail({ email }); const { messages, nextPageToken } = await queryBatchMessages(gmail, { query: query?.trim(), @@ -32,8 +32,6 @@ async function getMessages({ pageToken: pageToken ?? undefined, }); - const email = session.user.email; - // filter out SENT messages from the user // NOTE: -from:me doesn't work because it filters out messages from threads where the user responded const incomingMessages = messages.filter((message) => { @@ -69,7 +67,7 @@ async function getMessages({ return { messages: incomingMessages, nextPageToken }; } catch (error) { logger.error("Error getting messages", { - email: session?.user.email, + email, query, pageToken, error, @@ -78,11 +76,16 @@ async function getMessages({ } } -export const GET = withError(async (request) => { +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const { searchParams } = new URL(request.url); const query = searchParams.get("q"); const pageToken = searchParams.get("pageToken"); const r = messageQuerySchema.parse({ q: query, pageToken }); - const result = await getMessages({ query: r.q, pageToken: r.pageToken }); + const result = await getMessages({ + email, + query: r.q, + pageToken: r.pageToken, + }); return NextResponse.json(result); }); diff --git a/apps/web/utils/account.ts b/apps/web/utils/account.ts index 750e47607..2ebbe1d8b 100644 --- a/apps/web/utils/account.ts +++ b/apps/web/utils/account.ts @@ -20,3 +20,22 @@ export async function getGmailClientForEmail({ email }: { email: string }) { const gmail = getGmailClient(tokens); return gmail; } + +export async function getGmailClientForAccountId({ + accountId, +}: { + accountId: string; +}) { + const account = await prisma.account.findUnique({ + where: { id: accountId }, + select: { + access_token: true, + refresh_token: true, + }, + }); + const gmail = getGmailClient({ + accessToken: account?.access_token ?? undefined, + refreshToken: account?.refresh_token ?? undefined, + }); + return gmail; +} From 77f7566791c42bfe0fe7102900c0e4ece38788ba Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:18:15 +0300 Subject: [PATCH 065/176] move over more email --- .../api/google/messages/attachment/route.ts | 16 +++++-------- .../app/api/google/messages/batch/route.ts | 20 +++++++--------- apps/web/app/api/google/threads/[id]/route.ts | 14 ++++------- .../api/google/threads/archive/controller.ts | 24 ------------------- .../app/api/google/threads/archive/route.ts | 14 ----------- .../web/app/api/google/threads/basic/route.ts | 14 ++++------- apps/web/app/api/user/bulk-archive/route.ts | 14 ++++------- apps/web/app/api/user/categories/route.ts | 13 ++++------ .../app/api/user/stats/newsletters/helpers.ts | 9 +------ .../app/api/user/stats/newsletters/route.ts | 5 +++- apps/web/app/api/user/stats/route.ts | 14 ++++------- .../app/api/user/stats/sender-emails/route.ts | 17 ++++++------- apps/web/app/api/user/stats/senders/route.ts | 11 ++++----- apps/web/app/api/user/stats/tinybird/route.ts | 11 ++++----- 14 files changed, 59 insertions(+), 137 deletions(-) delete mode 100644 apps/web/app/api/google/threads/archive/controller.ts delete mode 100644 apps/web/app/api/google/threads/archive/route.ts diff --git a/apps/web/app/api/google/messages/attachment/route.ts b/apps/web/app/api/google/messages/attachment/route.ts index 648984cd4..7496db23e 100644 --- a/apps/web/app/api/google/messages/attachment/route.ts +++ b/apps/web/app/api/google/messages/attachment/route.ts @@ -1,9 +1,8 @@ import { z } from "zod"; -import { type NextRequest, NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { NextResponse } from "next/server"; +import { withAuth } from "@/utils/middleware"; import { getGmailAttachment } from "@/utils/gmail/attachment"; -import { getGmailClient } from "@/utils/gmail/client"; +import { getGmailClientForEmail } from "@/utils/account"; const attachmentQuery = z.object({ messageId: z.string(), @@ -14,12 +13,9 @@ const attachmentQuery = z.object({ // export type AttachmentQuery = z.infer; // export type AttachmentResponse = Awaited>; -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const gmail = getGmailClient(session); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const gmail = await getGmailClientForEmail({ email }); const { searchParams } = new URL(request.url); diff --git a/apps/web/app/api/google/messages/batch/route.ts b/apps/web/app/api/google/messages/batch/route.ts index 534ff6823..b00c37158 100644 --- a/apps/web/app/api/google/messages/batch/route.ts +++ b/apps/web/app/api/google/messages/batch/route.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getGmailAccessToken } from "@/utils/gmail/client"; import { uniq } from "lodash"; import { getMessagesBatch } from "@/utils/gmail/message"; import { parseReply } from "@/utils/mail"; +import { getTokens } from "@/utils/account"; const messagesBatchQuery = z.object({ ids: z @@ -19,10 +19,13 @@ export type MessagesBatchResponse = { messages: Awaited>; }; -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const tokens = await getTokens({ email }); + const accessToken = await getGmailAccessToken(tokens); + + if (!accessToken.token) + return NextResponse.json({ error: "Invalid access token" }); const { searchParams } = new URL(request.url); const ids = searchParams.get("ids"); @@ -32,11 +35,6 @@ export const GET = withError(async (request) => { parseReplies: parseReplies === "true", }); - const accessToken = await getGmailAccessToken(session); - - if (!accessToken.token) - return NextResponse.json({ error: "Invalid access token" }); - const messages = await getMessagesBatch(query.ids, accessToken.token); const result = query.parseReplies diff --git a/apps/web/app/api/google/threads/[id]/route.ts b/apps/web/app/api/google/threads/[id]/route.ts index 9aca9eeb4..b87fb1641 100644 --- a/apps/web/app/api/google/threads/[id]/route.ts +++ b/apps/web/app/api/google/threads/[id]/route.ts @@ -2,9 +2,8 @@ import { z } from "zod"; import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; import { parseMessages } from "@/utils/mail"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getGmailClient } from "@/utils/gmail/client"; -import { withError } from "@/utils/middleware"; +import { getGmailClientForEmail } from "@/utils/account"; +import { withAuth } from "@/utils/middleware"; import { getThread as getGmailThread } from "@/utils/gmail/thread"; export const dynamic = "force-dynamic"; @@ -28,15 +27,12 @@ async function getThread( return { thread: { ...thread, messages } }; } -export const GET = withError(async (request, context) => { +export const GET = withAuth(async (request, context) => { const params = await context.params; const { id } = threadQuery.parse(params); - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const gmail = getGmailClient(session); + const email = request.auth.userEmail; + const gmail = await getGmailClientForEmail({ email }); const { searchParams } = new URL(request.url); const includeDrafts = searchParams.get("includeDrafts") === "true"; diff --git a/apps/web/app/api/google/threads/archive/controller.ts b/apps/web/app/api/google/threads/archive/controller.ts deleted file mode 100644 index 3fa9f2547..000000000 --- a/apps/web/app/api/google/threads/archive/controller.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; -import { getGmailClient } from "@/utils/gmail/client"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { archiveThread } from "@/utils/gmail/label"; -import { SafeError } from "@/utils/error"; - -export const archiveBody = z.object({ id: z.string() }); -export type ArchiveBody = z.infer; -export type ArchiveResponse = Awaited>; - -export async function archiveEmail(body: ArchiveBody) { - const session = await auth(); - if (!session?.user.email) throw new SafeError("Not authenticated"); - - const gmail = getGmailClient(session); - const thread = await archiveThread({ - gmail, - threadId: body.id, - ownerEmail: session.user.email, - actionSource: "user", - }); - - return { thread }; -} diff --git a/apps/web/app/api/google/threads/archive/route.ts b/apps/web/app/api/google/threads/archive/route.ts deleted file mode 100644 index eef487ac2..000000000 --- a/apps/web/app/api/google/threads/archive/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextResponse } from "next/server"; -import { withError } from "@/utils/middleware"; -import { - archiveBody, - archiveEmail, -} from "@/app/api/google/threads/archive/controller"; - -export const POST = withError(async (request: Request) => { - const json = await request.json(); - const body = archiveBody.parse(json); - - const thread = await archiveEmail(body); - return NextResponse.json(thread); -}); diff --git a/apps/web/app/api/google/threads/basic/route.ts b/apps/web/app/api/google/threads/basic/route.ts index 1087c32b6..417e33ead 100644 --- a/apps/web/app/api/google/threads/basic/route.ts +++ b/apps/web/app/api/google/threads/basic/route.ts @@ -1,10 +1,9 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import type { gmail_v1 } from "@googleapis/gmail"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getThreads } from "@/utils/gmail/thread"; -import { getGmailClient } from "@/utils/gmail/client"; +import { getGmailClientForEmail } from "@/utils/account"; export type GetThreadsResponse = Awaited>; const getThreadsQuery = z.object({ @@ -26,18 +25,15 @@ async function getGetThreads( return threads.threads || []; } -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const gmail = await getGmailClientForEmail({ email }); const { searchParams } = new URL(request.url); const from = searchParams.get("from"); const labelId = searchParams.get("labelId"); const query = getThreadsQuery.parse({ from, labelId }); - const gmail = getGmailClient(session); - const result = await getGetThreads(query, gmail); return NextResponse.json(result); diff --git a/apps/web/app/api/user/bulk-archive/route.ts b/apps/web/app/api/user/bulk-archive/route.ts index bfcc5e19e..e0ab59bd9 100644 --- a/apps/web/app/api/user/bulk-archive/route.ts +++ b/apps/web/app/api/user/bulk-archive/route.ts @@ -1,16 +1,15 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import type { gmail_v1 } from "@googleapis/gmail"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getGmailClient } from "@/utils/gmail/client"; import { GmailLabel, getOrCreateInboxZeroLabel, labelThread, } from "@/utils/gmail/label"; import { sleep } from "@/utils/sleep"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getThreads } from "@/utils/gmail/thread"; +import { getGmailClientForEmail } from "@/utils/account"; const bulkArchiveBody = z.object({ daysAgo: z.string() }); export type BulkArchiveBody = z.infer; @@ -50,16 +49,13 @@ async function bulkArchive(body: BulkArchiveBody, gmail: gmail_v1.Gmail) { return { count: threads?.length || 0 }; } -export const POST = withError(async (request: Request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const POST = withAuth(async (request) => { + const email = request.auth.userEmail; + const gmail = await getGmailClientForEmail({ email }); const json = await request.json(); const body = bulkArchiveBody.parse(json); - const gmail = getGmailClient(session); - const result = await bulkArchive(body, gmail); return NextResponse.json(result); diff --git a/apps/web/app/api/user/categories/route.ts b/apps/web/app/api/user/categories/route.ts index 35a5a9f37..6e332692e 100644 --- a/apps/web/app/api/user/categories/route.ts +++ b/apps/web/app/api/user/categories/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getUserCategories } from "@/utils/category.server"; export type UserCategoriesResponse = Awaited>; @@ -10,12 +9,8 @@ async function getCategories({ email }: { email: string }) { return { result }; } -export const GET = withError(async () => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - - const result = await getCategories({ email: session.user.email }); - +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const result = await getCategories({ email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/newsletters/helpers.ts b/apps/web/app/api/user/stats/newsletters/helpers.ts index 8f57c56ee..a28c4d344 100644 --- a/apps/web/app/api/user/stats/newsletters/helpers.ts +++ b/apps/web/app/api/user/stats/newsletters/helpers.ts @@ -1,18 +1,11 @@ import type { gmail_v1 } from "@googleapis/gmail"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { extractEmailAddress } from "@/utils/email"; -import { getGmailClient } from "@/utils/gmail/client"; import { getFiltersList } from "@/utils/gmail/filter"; import prisma from "@/utils/prisma"; import { NewsletterStatus } from "@prisma/client"; import { GmailLabel } from "@/utils/gmail/label"; -import { SafeError } from "@/utils/error"; - -export async function getAutoArchiveFilters() { - const session = await auth(); - if (!session?.user.email) throw new SafeError("Not logged in"); - const gmail = getGmailClient(session); +export async function getAutoArchiveFilters(gmail: gmail_v1.Gmail) { const filters = await getFiltersList({ gmail }); const autoArchiveFilters = filters.data.filter?.filter(isAutoArchiveFilter); diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index 37de68f3a..59ca29e4a 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -10,6 +10,7 @@ import { import prisma from "@/utils/prisma"; import { Prisma } from "@prisma/client"; import { extractEmailAddress } from "@/utils/email"; +import { getGmailClientForEmail } from "@/utils/account"; // not sure why this is slow sometimes export const maxDuration = 30; @@ -71,13 +72,15 @@ async function getNewslettersTinybird( const emailAccountId = options.emailAccountId; const types = getTypeFilters(options.types); + const gmail = await getGmailClientForEmail({ email: emailAccountId }); + const [newsletterCounts, autoArchiveFilters, userNewsletters] = await Promise.all([ getNewsletterCounts({ ...options, ...types, }), - getAutoArchiveFilters(), + getAutoArchiveFilters(gmail), findNewsletterStatus({ emailAccountId }), ]); diff --git a/apps/web/app/api/user/stats/route.ts b/apps/web/app/api/user/stats/route.ts index 130719cf2..e35854d0f 100644 --- a/apps/web/app/api/user/stats/route.ts +++ b/apps/web/app/api/user/stats/route.ts @@ -1,10 +1,9 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getGmailClient } from "@/utils/gmail/client"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { dateToSeconds } from "@/utils/date"; import { getMessages } from "@/utils/gmail/message"; +import { getGmailClientForEmail } from "@/utils/account"; export type StatsResponse = Awaited>; @@ -73,12 +72,9 @@ async function getStats(options: { gmail: gmail_v1.Gmail }) { }; } -export const GET = withError(async () => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const gmail = getGmailClient(session); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const gmail = await getGmailClientForEmail({ email }); const result = await getStats({ gmail }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/sender-emails/route.ts b/apps/web/app/api/user/stats/sender-emails/route.ts index c2c969d19..3b4bb8c99 100644 --- a/apps/web/app/api/user/stats/sender-emails/route.ts +++ b/apps/web/app/api/user/stats/sender-emails/route.ts @@ -1,9 +1,8 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import format from "date-fns/format"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { zodPeriod } from "@inboxzero/tinybird"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import prisma from "@/utils/prisma"; import { Prisma } from "@prisma/client"; @@ -17,9 +16,9 @@ export type SenderEmailsQuery = z.infer; export type SenderEmailsResponse = Awaited>; async function getSenderEmails( - options: SenderEmailsQuery & { userId: string }, + options: SenderEmailsQuery & { emailAccountId: string }, ) { - const { fromEmail, period, fromDate, toDate, userId } = options; + const { fromEmail, period, fromDate, toDate, emailAccountId } = options; // Define the date truncation function based on the period let dateFunction: string; @@ -37,7 +36,7 @@ async function getSenderEmails( let query = Prisma.sql` SELECT ${Prisma.raw(dateFunction)} AS "startOfPeriod", COUNT(*) as count FROM "EmailMessage" - WHERE "userId" = ${userId} + WHERE "emailAccountId" = ${emailAccountId} AND "from" = ${fromEmail} `; @@ -70,10 +69,8 @@ async function getSenderEmails( }; } -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const { searchParams } = new URL(request.url); @@ -86,7 +83,7 @@ export const GET = withError(async (request) => { const result = await getSenderEmails({ ...query, - userId: session.user.id, + emailAccountId: email, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/senders/route.ts b/apps/web/app/api/user/stats/senders/route.ts index 20e9032ba..8dc82d974 100644 --- a/apps/web/app/api/user/stats/senders/route.ts +++ b/apps/web/app/api/user/stats/senders/route.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getEmailFieldStats } from "@/app/api/user/stats/helpers"; const senderStatsQuery = z.object({ @@ -80,10 +79,8 @@ async function getDomainsMostReceivedFrom({ }); } -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const { searchParams } = new URL(request.url); const query = senderStatsQuery.parse({ @@ -93,7 +90,7 @@ export const GET = withError(async (request) => { const result = await getSenderStatistics({ ...query, - emailAccountId: session.user.email, + emailAccountId: email, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/tinybird/route.ts b/apps/web/app/api/user/stats/tinybird/route.ts index 9b5190668..96704cb9f 100644 --- a/apps/web/app/api/user/stats/tinybird/route.ts +++ b/apps/web/app/api/user/stats/tinybird/route.ts @@ -3,8 +3,7 @@ import format from "date-fns/format"; import { z } from "zod"; import sumBy from "lodash/sumBy"; import { zodPeriod } from "@inboxzero/tinybird"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import prisma from "@/utils/prisma"; import { Prisma } from "@prisma/client"; @@ -139,10 +138,8 @@ async function getStatsByPeriod( }; } -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const { searchParams } = new URL(request.url); const params = statsByWeekParams.parse({ @@ -152,7 +149,7 @@ export const GET = withError(async (request) => { }); const result = await getStatsByPeriod({ - ownerEmail: session.user.email, + ownerEmail: email, ...params, }); From e3a58505200d17f44c8bca16f3a72c49b9d4e1e7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:30:28 +0300 Subject: [PATCH 066/176] more fixes --- .../[account]/automation/onboarding/page.tsx | 19 +++++++-------- .../automation/rule/[ruleId]/page.tsx | 9 ++----- .../[account]/reply-zero/onboarding/page.tsx | 12 ++++------ .../api/user/group/[groupId]/rules/route.ts | 11 ++++----- .../app/api/user/stats/tinybird/load/route.ts | 24 ++++++++++--------- 5 files changed, 33 insertions(+), 42 deletions(-) diff --git a/apps/web/app/(app)/[account]/automation/onboarding/page.tsx b/apps/web/app/(app)/[account]/automation/onboarding/page.tsx index a8ce3e930..7d0c0167e 100644 --- a/apps/web/app/(app)/[account]/automation/onboarding/page.tsx +++ b/apps/web/app/(app)/[account]/automation/onboarding/page.tsx @@ -1,6 +1,5 @@ import { Card } from "@/components/ui/card"; import { CategoriesSetup } from "./CategoriesSetup"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { ActionType, @@ -10,12 +9,12 @@ import { } from "@prisma/client"; import type { CategoryAction } from "@/utils/actions/rule.validation"; -export default async function OnboardingPage() { - const session = await auth(); - const email = session?.user.email; - if (!email) return
Not authenticated
; - - const defaultValues = await getUserPreferences({ email }); +export default async function OnboardingPage({ + params, +}: { + params: { account: string }; +}) { + const defaultValues = await getUserPreferences({ accountId: params.account }); return ( @@ -39,12 +38,12 @@ type UserPreferences = Prisma.EmailAccountGetPayload<{ }>; async function getUserPreferences({ - email, + accountId, }: { - email: string; + accountId: string; }) { const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { accountId }, select: { rules: { select: { diff --git a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx index a32cca7b5..739065f79 100644 --- a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx +++ b/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx @@ -1,23 +1,18 @@ -import { redirect } from "next/navigation"; import prisma from "@/utils/prisma"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { RuleForm } from "@/app/(app)/[account]/automation/RuleForm"; import { TopSection } from "@/components/TopSection"; import { hasVariables } from "@/utils/template"; import { getConditions } from "@/utils/condition"; export default async function RulePage(props: { - params: Promise<{ ruleId: string }>; + params: Promise<{ ruleId: string; account: string }>; searchParams: Promise<{ new: string }>; }) { const searchParams = await props.searchParams; const params = await props.params; - const session = await auth(); - const email = session?.user.email; - if (!email) redirect("/login"); const rule = await prisma.rule.findUnique({ - where: { id: params.ruleId, emailAccountId: email }, + where: { id: params.ruleId, emailAccount: { accountId: params.account } }, include: { actions: true, categoryFilters: true, diff --git a/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx b/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx index bf984ca32..ab5cea2ea 100644 --- a/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx +++ b/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx @@ -1,17 +1,15 @@ -import { redirect } from "next/navigation"; import { EnableReplyTracker } from "@/app/(app)/[account]/reply-zero/EnableReplyTracker"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { ActionType } from "@prisma/client"; -export default async function OnboardingReplyTracker() { - const session = await auth(); - const emailAccountId = session?.user.email; - if (!emailAccountId) redirect("/login"); +export default async function OnboardingReplyTracker(props: { + params: Promise<{ account: string }>; +}) { + const params = await props.params; const trackerRule = await prisma.rule.findFirst({ where: { - emailAccountId, + emailAccount: { accountId: params.account }, actions: { some: { type: ActionType.TRACK_THREAD } }, }, select: { id: true }, diff --git a/apps/web/app/api/user/group/[groupId]/rules/route.ts b/apps/web/app/api/user/group/[groupId]/rules/route.ts index 5849e2005..ec5a6856c 100644 --- a/apps/web/app/api/user/group/[groupId]/rules/route.ts +++ b/apps/web/app/api/user/group/[groupId]/rules/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { SafeError } from "@/utils/error"; export type GroupRulesResponse = Awaited>; @@ -29,15 +28,13 @@ async function getGroupRules({ return { rule: groupWithRules.rule }; } -export const GET = withError(async (_request: Request, { params }) => { - const session = await auth(); - const emailAccountId = session?.user.email; - if (!emailAccountId) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request, { params }) => { + const email = request.auth.userEmail; const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Group id required" }); - const result = await getGroupRules({ emailAccountId, groupId }); + const result = await getGroupRules({ emailAccountId: email, groupId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/tinybird/load/route.ts b/apps/web/app/api/user/stats/tinybird/load/route.ts index 316cc0b0c..de7950067 100644 --- a/apps/web/app/api/user/stats/tinybird/load/route.ts +++ b/apps/web/app/api/user/stats/tinybird/load/route.ts @@ -1,32 +1,34 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { withError } from "@/utils/middleware"; -import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; +import { withAuth } from "@/utils/middleware"; import { loadEmails } from "@/app/api/user/stats/tinybird/load/load-emails"; import { loadTinybirdEmailsBody } from "@/app/api/user/stats/tinybird/load/validation"; +import { getTokens } from "@/utils/account"; +import { getGmailClient } from "@/utils/gmail/client"; +import { getGmailAccessToken } from "@/utils/gmail/client"; export const maxDuration = 90; export type LoadTinybirdEmailsResponse = Awaited>; -export const POST = withError(async (request: Request) => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const POST = withAuth(async (request) => { + const email = request.auth.userEmail; const json = await request.json(); const body = loadTinybirdEmailsBody.parse(json); - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); + const tokens = await getTokens({ email }); - if (!token.token) return NextResponse.json({ error: "Missing access token" }); + const gmail = getGmailClient(tokens); + const token = await getGmailAccessToken(tokens); + + const accessToken = token.token; + if (!accessToken) return NextResponse.json({ error: "Missing access token" }); const result = await loadEmails( { emailAccountId: email, gmail, - accessToken: token.token, + accessToken, }, body, ); From c10129006a1696de6a34a9faaeff43b8e1b2d303 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:40:16 +0300 Subject: [PATCH 067/176] fix more user email --- .../(app)/[account]/simple/completed/page.tsx | 35 +++++++++++++++---- .../web/app/api/google/threads/batch/route.ts | 23 ++++++------ apps/web/app/api/google/threads/controller.ts | 23 ++++++------ apps/web/app/api/google/threads/route.ts | 24 +++++++++++-- apps/web/app/api/resend/summary/route.ts | 10 ++---- apps/web/app/api/user/draft-actions/route.ts | 10 ++---- .../api/user/group/[groupId]/items/route.ts | 9 ++--- 7 files changed, 84 insertions(+), 50 deletions(-) diff --git a/apps/web/app/(app)/[account]/simple/completed/page.tsx b/apps/web/app/(app)/[account]/simple/completed/page.tsx index 03b50b31e..290ffa04e 100644 --- a/apps/web/app/(app)/[account]/simple/completed/page.tsx +++ b/apps/web/app/(app)/[account]/simple/completed/page.tsx @@ -5,17 +5,40 @@ import { EmailList } from "@/components/email-list/EmailList"; import { getThreads } from "@/app/api/google/threads/controller"; import { Button } from "@/components/ui/button"; import { getGmailBasicSearchUrl } from "@/utils/url"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { OpenMultipleGmailButton } from "@/app/(app)/[account]/simple/completed/OpenMultipleGmailButton"; import { SimpleProgressCompleted } from "@/app/(app)/[account]/simple/SimpleProgress"; import { ShareOnTwitterButton } from "@/app/(app)/[account]/simple/completed/ShareOnTwitterButton"; +import { getTokens } from "@/utils/account"; +import { getGmailClient, getGmailAccessToken } from "@/utils/gmail/client"; +import prisma from "@/utils/prisma"; -export default async function SimpleCompletedPage() { - const session = await auth(); - const email = session?.user.email; - if (!email) throw new Error("Not authenticated"); +export default async function SimpleCompletedPage(props: { + params: Promise<{ account: string }>; +}) { + const params = await props.params; - const { threads } = await getThreads({ q: "newer_than:1d in:inbox" }); + const tokens = await getTokens({ email: params.account }); + + const gmail = getGmailClient(tokens); + const token = await getGmailAccessToken(tokens); + + if (!token.token) throw new Error("Account not found"); + + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email: params.account }, + select: { email: true }, + }); + + if (!emailAccount) throw new Error("Account not found"); + + const email = emailAccount.email; + + const { threads } = await getThreads({ + query: { q: "newer_than:1d in:inbox" }, + gmail, + accessToken: token.token, + email: emailAccount.email, + }); return (
diff --git a/apps/web/app/api/google/threads/batch/route.ts b/apps/web/app/api/google/threads/batch/route.ts index 5a607d69b..015a51f63 100644 --- a/apps/web/app/api/google/threads/batch/route.ts +++ b/apps/web/app/api/google/threads/batch/route.ts @@ -1,8 +1,9 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { NextResponse } from "next/server"; import { z } from "zod"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; +import { getTokens } from "@/utils/account"; +import { getGmailAccessToken } from "@/utils/gmail/client"; const requestSchema = z.object({ threadIds: z.array(z.string()), @@ -13,10 +14,8 @@ export type ThreadsBatchResponse = Awaited< ReturnType >; -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user) - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const { searchParams } = new URL(request.url); const { threadIds, includeDrafts } = requestSchema.parse({ @@ -28,8 +27,12 @@ export const GET = withError(async (request) => { return NextResponse.json({ threads: [] } satisfies ThreadsBatchResponse); } - const accessToken = session.accessToken; - if (!accessToken) + const tokens = await getTokens({ email }); + if (!tokens) return NextResponse.json({ error: "Account not found" }); + + const token = await getGmailAccessToken(tokens); + + if (!token.token) return NextResponse.json( { error: "Missing access token" }, { status: 401 }, @@ -37,7 +40,7 @@ export const GET = withError(async (request) => { const response = await getThreadsBatchAndParse( threadIds, - accessToken, + token.token, includeDrafts, ); diff --git a/apps/web/app/api/google/threads/controller.ts b/apps/web/app/api/google/threads/controller.ts index 5c88a9551..eafea87ae 100644 --- a/apps/web/app/api/google/threads/controller.ts +++ b/apps/web/app/api/google/threads/controller.ts @@ -1,6 +1,5 @@ import { parseMessages } from "@/utils/mail"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; +import type { gmail_v1 } from "@googleapis/gmail"; import { GmailLabel } from "@/utils/gmail/label"; import { isDefined } from "@/utils/types"; import prisma from "@/utils/prisma"; @@ -16,15 +15,17 @@ import { SafeError } from "@/utils/error"; export type ThreadsResponse = Awaited>; -export async function getThreads(query: ThreadsQuery) { - const session = await auth(); - const email = session?.user.email; - if (!email) throw new SafeError("Not authenticated"); - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - const accessToken = token?.token; - +export async function getThreads({ + query, + gmail, + accessToken, + email, +}: { + query: ThreadsQuery; + gmail: gmail_v1.Gmail; + accessToken: string; + email: string; +}) { if (!accessToken) throw new SafeError("Missing access token"); function getQuery() { diff --git a/apps/web/app/api/google/threads/route.ts b/apps/web/app/api/google/threads/route.ts index 2b4bc3ef8..d210afa86 100644 --- a/apps/web/app/api/google/threads/route.ts +++ b/apps/web/app/api/google/threads/route.ts @@ -1,13 +1,18 @@ import { NextResponse } from "next/server"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { getThreads } from "@/app/api/google/threads/controller"; import { threadsQuery } from "@/app/api/google/threads/validation"; +import { getGmailAccessToken } from "@/utils/gmail/client"; +import { getTokens } from "@/utils/account"; +import { getGmailClient } from "@/utils/gmail/client"; export const dynamic = "force-dynamic"; export const maxDuration = 30; -export const GET = withError(async (request) => { +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; + const { searchParams } = new URL(request.url); const limit = searchParams.get("limit"); const fromEmail = searchParams.get("fromEmail"); @@ -24,6 +29,19 @@ export const GET = withError(async (request) => { labelId, }); - const threads = await getThreads(query); + const tokens = await getTokens({ email }); + if (!tokens) return NextResponse.json({ error: "Account not found" }); + + const gmail = getGmailClient(tokens); + const token = await getGmailAccessToken(tokens); + + if (!token.token) return NextResponse.json({ error: "Account not found" }); + + const threads = await getThreads({ + query, + email, + gmail, + accessToken: token.token, + }); return NextResponse.json(threads); }); diff --git a/apps/web/app/api/resend/summary/route.ts b/apps/web/app/api/resend/summary/route.ts index 749d66bb9..1b76bf119 100644 --- a/apps/web/app/api/resend/summary/route.ts +++ b/apps/web/app/api/resend/summary/route.ts @@ -2,13 +2,12 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import subHours from "date-fns/subHours"; import { sendSummaryEmail } from "@inboxzero/resend"; -import { withError } from "@/utils/middleware"; +import { withAuth, withError } from "@/utils/middleware"; import { env } from "@/env"; import { hasCronSecret } from "@/utils/cron"; import { captureException } from "@/utils/error"; import prisma from "@/utils/prisma"; import { ExecutedRuleStatus } from "@prisma/client"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { ThreadTrackerType } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; import { getMessagesBatch } from "@/utils/gmail/message"; @@ -246,12 +245,9 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { return { success: true }; } -export const GET = withError(async () => { - const session = await auth(); - +export const GET = withAuth(async (request) => { // send to self - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); + const email = request.auth.userEmail; logger.info("Sending summary email to user GET", { email }); diff --git a/apps/web/app/api/user/draft-actions/route.ts b/apps/web/app/api/user/draft-actions/route.ts index 64e6f5dad..bf430bf9c 100644 --- a/apps/web/app/api/user/draft-actions/route.ts +++ b/apps/web/app/api/user/draft-actions/route.ts @@ -1,16 +1,12 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; import { ActionType } from "@prisma/client"; export type DraftActionsResponse = Awaited>; -export const GET = withError(async () => { - const session = await auth(); - const email = session?.user.email; - if (!email) - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); +export const GET = withAuth(async (request) => { + const email = request.auth.userEmail; const response = await getData({ email }); diff --git a/apps/web/app/api/user/group/[groupId]/items/route.ts b/apps/web/app/api/user/group/[groupId]/items/route.ts index 40db41c4b..6d1382b62 100644 --- a/apps/web/app/api/user/group/[groupId]/items/route.ts +++ b/apps/web/app/api/user/group/[groupId]/items/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; export type GroupItemsResponse = Awaited>; @@ -24,10 +23,8 @@ async function getGroupItems({ return { group }; } -export const GET = withError(async (_request: Request, { params }) => { - const session = await auth(); - const email = session?.user.email; - if (!email) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withAuth(async (request, { params }) => { + const email = request.auth.userEmail; const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Group id required" }); From fc2366307d6339b89b34f9fbe47391290c17e64e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:43:04 +0300 Subject: [PATCH 068/176] update mdcs --- .cursor/rules/api-routes.mdc | 75 --------------------------------- .cursor/rules/get-api-route.mdc | 28 +++++------- .cursor/rules/index.mdc | 1 - 3 files changed, 10 insertions(+), 94 deletions(-) delete mode 100644 .cursor/rules/api-routes.mdc diff --git a/.cursor/rules/api-routes.mdc b/.cursor/rules/api-routes.mdc deleted file mode 100644 index 43ca6b54e..000000000 --- a/.cursor/rules/api-routes.mdc +++ /dev/null @@ -1,75 +0,0 @@ ---- -description: Guidelines for implementing Next.js API routes -globs: -alwaysApply: false ---- -# API Routes - -## Standard Format - -Use this format for API routes: - -```ts -import { z } from "zod"; -import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; - -const apiNameBody = z.object({ id: z.string(), message: z.string() }); -export type ApiNameBody = z.infer; -export type UpdateApiNameResponse = Awaited>; - -async function updateApiName(body: ApiNameBody, options: { email: string }) { - const { email } = options; - const result = await prisma.table.update({ - where: { - id: body.id, - email, - }, - data: body, - }); - - return { result }; -} - -// For routes without params -export const POST = withError(async (request: Request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const json = await request.json(); - const body = apiNameBody.parse(json); - - const result = await updateApiName(body, { email: session.user.email }); - - return NextResponse.json(result); -}); - -// For routes with params (note the params promise which is how Next.js 15+ works) -export const GET = withError( - async ( - request: Request, - { params }: { params: Promise<{ slug: string }> } - ) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const { slug } = await params; - // Use the slug parameter... - - return NextResponse.json({ result }); - } -); -``` - -## Implementation Guidelines - -- Use Zod for request body validation -- Create separate functions for business logic -- Wrap route handlers with `withError` middleware -- Always validate authentication with `auth()` -- Export typed responses for client usage -- For routes with dynamic parameters, use the new Next.js 15+ params format with async params diff --git a/.cursor/rules/get-api-route.mdc b/.cursor/rules/get-api-route.mdc index 09d7f9b84..8e4bdfa0e 100644 --- a/.cursor/rules/get-api-route.mdc +++ b/.cursor/rules/get-api-route.mdc @@ -11,29 +11,21 @@ Basic Structure. Note how we auto generate the response type for use on the clie ```typescript import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withAuth } from "@/utils/middleware"; export type GetExampleResponse = Awaited>; -export const GET = withError(async () => { - const session = await auth(); - if (!session?.user.id) - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); +export const GET = withAuth(async () => { + const email = request.auth.userEmail; - const result = getData(session.user.id); + const result = getData({ email }); return NextResponse.json(result); }); -async function getData(userId: string) { +async function getData({ email }: { email: string }) { const items = await prisma.example.findMany({ - where: { - userId: session.user.id, - }, - orderBy: { - updatedAt: "desc", - }, + where: { email }, }); return { items }; @@ -44,8 +36,8 @@ See [data-fetching.mdc](mdc:.cursor/rules/data-fetching.mdc) as to how this woul Key Requirements: - - Always wrap the handler with `withError` for consistent error handling. We don't need try/catch - - Always check authentication using `auth()` - - Infer and export response type - - Use Prisma for database queries. See [prisma.mdc](mdc:.cursor/rules/prisma.mdc) + - Always wrap the handler with `withAuth` for consistent error handling and authentication. + - We don't need try/catch as `withAuth` handles that. + - Infer and export response type. + - Use Prisma for database queries. - Return responses using `NextResponse.json()` diff --git a/.cursor/rules/index.mdc b/.cursor/rules/index.mdc index f0d9d25d2..fba2393a4 100644 --- a/.cursor/rules/index.mdc +++ b/.cursor/rules/index.mdc @@ -39,7 +39,6 @@ Guidelines for implementing backend logic, APIs, and data persistence. | Rule File | Description | | :--------------------------------- | :---------------------------------------------------------- | -| @api-routes.mdc | Guidelines for implementing Next.js API routes (general) | | @get-api-route.mdc | Guidelines for implementing GET API routes in Next.js | | @server-actions.mdc | Guidelines for implementing Next.js server actions | | @prisma.mdc | How to use Prisma | From 156adb74af5d4eb0383f62c3ff251866031f0d32 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:44:14 +0300 Subject: [PATCH 069/176] snippets --- .vscode/typescriptreact.code-snippets | 51 +++++---------------------- 1 file changed, 9 insertions(+), 42 deletions(-) diff --git a/.vscode/typescriptreact.code-snippets b/.vscode/typescriptreact.code-snippets index 4032f4a61..b50fe81cb 100644 --- a/.vscode/typescriptreact.code-snippets +++ b/.vscode/typescriptreact.code-snippets @@ -33,7 +33,7 @@ "import { NextResponse } from \"next/server\";", "import { auth } from \"@/app/api/auth/[...nextauth]/auth\";", "import prisma from \"@/utils/prisma\";", - "import { withError } from \"@/utils/middleware\";", + "import { withAuth } from \"@/utils/middleware\";", "", "export type ${1:ApiName}Response = Awaited<", " ReturnType", @@ -48,10 +48,7 @@ " return { result };", "}", "", - "export const GET = withError(async (request) => {", - " const session = await auth();", - " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", - "", + "export const GET = withAuth(async (request) => {", " const result = await get${1:ApiName}({ userId: session.user.id });", "", " return NextResponse.json(result);", @@ -61,10 +58,7 @@ "export type ${1/(.*)/${1:/downcase}/}Body = z.infer;", "export type update${1:ApiName}Response = Awaited>;", "", - "export const POST = withError(async (request) => {", - " const session = await auth();", - " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", - "", + "export const POST = withAuth(async (request) => {", " const json = await request.json();", " const body = ${1/(.*)/${1:/downcase}/}Body.parse(json);", "", @@ -78,21 +72,6 @@ "", " return NextResponse.json(result);", "});", - "", - "", - "export const DELETE = withError(async (_request, { params }) => {", - " const session = await auth();", - " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", - "", - " const result = await prisma.${2:table}.delete({", - " where: {", - " id: params.id,", - " userId: session.user.id,", - " }", - " })", - "", - " return NextResponse.json(result);", - "});", "" ], "description": "Next API Route" @@ -104,7 +83,7 @@ "import { NextResponse } from \"next/server\";", "import { auth } from \"@/app/api/auth/[...nextauth]/auth\";", "import prisma from \"@/utils/prisma\";", - "import { withError } from \"@/utils/middleware\";", + "import { withAuth } from \"@/utils/middleware\";", "", "export type ${1:ApiName}Response = Awaited<", " ReturnType", @@ -119,10 +98,7 @@ " return { result };", "};", "", - "export const GET = withError(async () => {", - " const session = await auth();", - " if (!session?.user.email) return NextResponse.json({ error: \"Not authenticated\" });", - "", + "export const GET = withAuth(async () => {", " const result = await get${1:ApiName}({ email: session.user.email });", "", " return NextResponse.json(result);", @@ -138,7 +114,7 @@ "import { NextResponse } from \"next/server\";", "import { auth } from \"@/app/api/auth/[...nextauth]/auth\";", "import prisma from \"@/utils/prisma\";", - "import { withError } from \"@/utils/middleware\";", + "import { withAuth } from \"@/utils/middleware\";", "", "const ${1:ApiName}Body = z.object({ id: z.string(), message: z.string() });", "export type ${1/(.*)/${1:/downcase}/}Body = z.infer;", @@ -157,10 +133,7 @@ " return { result };", "};", "", - "export const POST = withError(async (request: Request) => {", - " const session = await auth();", - " if (!session?.user.email) return NextResponse.json({ error: \"Not authenticated\" });", - "", + "export const POST = withAuth(async (request: Request) => {", " const json = await request.json();", " const body = ${1/(.*)/${1:/downcase}/}Body.parse(json);", "", @@ -198,7 +171,7 @@ "import { NextResponse } from \"next/server\";", "import { auth } from \"@/app/api/auth/[...nextauth]/auth\";", "import prisma from \"@/utils/prisma\";", - "import { withError } from \"@/utils/middleware\";", + "import { withAuth } from \"@/utils/middleware\";", "import {", " SaveSettingsBody,", " saveSettingsBody,", @@ -208,9 +181,6 @@ "export type SaveSettingsResponse = Awaited>;", "", "async function saveAISettings(options: SaveSettingsBody) {", - " const session = await auth();", - " if (!session?.user.email) throw new SafeError(\"Not logged in\");", - "", " return await prisma.user.update({", " where: { email: session.user.email },", " data: {", @@ -220,10 +190,7 @@ " });", "}", "", - "export const POST = withError(async (request: Request) => {", - " const session = await auth();", - " if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });", - "", + "export const POST = withAuth(async (request: Request) => {", " const json = await request.json();", " const body = saveSettingsBody.parse(json);", "", From cef50c3786857b6bf6f1b1226c49ba1f82dc3146 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 25 Apr 2025 12:57:49 +0300 Subject: [PATCH 070/176] fix params --- apps/web/app/(app)/[account]/automation/onboarding/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/[account]/automation/onboarding/page.tsx b/apps/web/app/(app)/[account]/automation/onboarding/page.tsx index 7d0c0167e..0f3837421 100644 --- a/apps/web/app/(app)/[account]/automation/onboarding/page.tsx +++ b/apps/web/app/(app)/[account]/automation/onboarding/page.tsx @@ -12,9 +12,10 @@ import type { CategoryAction } from "@/utils/actions/rule.validation"; export default async function OnboardingPage({ params, }: { - params: { account: string }; + params: Promise<{ account: string }>; }) { - const defaultValues = await getUserPreferences({ accountId: params.account }); + const { account } = await params; + const defaultValues = await getUserPreferences({ accountId: account }); return ( From e13a32e5133fc3d6f3d548f707913e0b4361a7ce Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:13:52 +0300 Subject: [PATCH 071/176] mid way through massive refactor --- .../PermissionsCheck.tsx | 2 +- .../assess.tsx | 2 +- .../automation/AutomationOnboarding.tsx | 0 .../automation/BulkRunRules.tsx | 2 +- .../automation/ExecutedRulesTable.tsx | 2 +- .../automation/History.tsx | 6 +- .../automation/Pending.tsx | 6 +- .../automation/PersonaDialog.tsx | 2 +- .../automation/Process.tsx | 2 +- .../automation/ProcessResultDisplay.tsx | 0 .../automation/ProcessRules.tsx | 10 +-- .../automation/ProcessingPromptFileDialog.tsx | 0 .../automation/ReportMistake.tsx | 6 +- .../automation/RuleForm.tsx | 4 +- .../automation/Rules.tsx | 4 +- .../automation/RulesPrompt.tsx | 10 +-- .../automation/RulesSelect.tsx | 0 .../automation/SetDateDropdown.tsx | 0 .../automation/TestCustomEmailForm.tsx | 4 +- .../automation/create/examples.tsx | 0 .../automation/create/page.tsx | 4 +- .../automation/examples.ts | 0 .../automation/group/Groups.tsx | 0 .../automation/group/LearnedPatterns.tsx | 2 +- .../automation/group/ViewGroup.tsx | 2 +- .../group/[groupId]/examples/page.tsx | 2 +- .../automation/group/[groupId]/page.tsx | 2 +- .../automation/knowledge/KnowledgeBase.tsx | 4 +- .../automation/knowledge/KnowledgeForm.tsx | 2 +- .../automation/onboarding/CategoriesSetup.tsx | 0 .../automation/onboarding/completed/page.tsx | 0 .../onboarding/draft-replies/page.tsx | 2 +- .../automation/onboarding/page.tsx | 2 +- .../automation/page.tsx | 29 ++++----- .../automation/rule/[ruleId]/error.tsx | 0 .../rule/[ruleId]/examples/example-list.tsx | 4 +- .../rule/[ruleId]/examples/page.tsx | 2 +- .../rule/[ruleId]/examples/types.ts | 0 .../automation/rule/[ruleId]/page.tsx | 2 +- .../automation/rule/create/page.tsx | 4 +- .../bulk-archive/page.tsx | 0 .../bulk-unsubscribe/ArchiveProgress.tsx | 0 .../bulk-unsubscribe/BulkActions.tsx | 4 +- .../bulk-unsubscribe/BulkUnsubscribe.tsx | 6 +- .../BulkUnsubscribeDesktop.tsx | 4 +- .../BulkUnsubscribeMobile.tsx | 4 +- .../BulkUnsubscribeSection.tsx | 28 ++++----- .../BulkUnsubscribeSummary.tsx | 0 .../bulk-unsubscribe/SearchBar.tsx | 0 .../bulk-unsubscribe/ShortcutTooltip.tsx | 0 .../bulk-unsubscribe/common.tsx | 4 +- .../bulk-unsubscribe/hooks.ts | 2 +- .../bulk-unsubscribe/page.tsx | 2 +- .../bulk-unsubscribe/types.ts | 0 .../clean/ActionSelectionStep.tsx | 2 +- .../clean/CleanHistory.tsx | 0 .../clean/CleanInstructionsStep.tsx | 4 +- .../clean/CleanRun.tsx | 4 +- .../clean/CleanStats.tsx | 0 .../clean/ConfirmationStep.tsx | 4 +- .../clean/EmailFirehose.tsx | 0 .../clean/EmailFirehoseItem.tsx | 0 .../clean/IntroStep.tsx | 2 +- .../clean/PreviewBatch.tsx | 4 +- .../clean/TimeRangeStep.tsx | 4 +- .../clean/consts.ts | 0 .../clean/helpers.ts | 4 +- .../clean/history/page.tsx | 2 +- .../clean/loading.tsx | 0 .../clean/onboarding/page.tsx | 20 +++--- .../clean/page.tsx | 17 +++--- .../clean/run/page.tsx | 13 ++-- .../clean/types.ts | 0 .../clean/useEmailStream.ts | 0 .../clean/useSkipSettings.ts | 0 .../clean/useStep.tsx | 2 +- .../cold-email-blocker/ColdEmailList.tsx | 4 +- .../ColdEmailPromptForm.tsx | 2 +- .../cold-email-blocker/ColdEmailRejected.tsx | 4 +- .../cold-email-blocker/ColdEmailSettings.tsx | 4 +- .../cold-email-blocker/ColdEmailTest.tsx | 2 +- .../cold-email-blocker/TestRules.tsx | 2 +- .../cold-email-blocker/page.tsx | 10 +-- .../compose/ComposeEmailForm.tsx | 2 +- .../compose/ComposeEmailFormLazy.tsx | 0 .../compose/page.tsx | 2 +- .../debug/drafts/page.tsx | 2 +- .../debug/learned/page.tsx | 2 +- .../debug/page.tsx | 0 .../early-access/EarlyAccessFeatures.tsx | 0 .../early-access/page.tsx | 2 +- .../mail/BetaBanner.tsx | 0 .../mail/page.tsx | 4 +- .../no-reply/page.tsx | 0 .../permissions/consent/page.tsx | 0 .../permissions/error/page.tsx | 0 .../premium/PremiumModal.tsx | 2 +- .../premium/Pricing.tsx | 2 +- .../premium/config.ts | 0 .../premium/page.tsx | 2 +- .../reply-zero/AwaitingReply.tsx | 11 ++-- .../reply-zero/EnableReplyTracker.tsx | 2 +- .../reply-zero/NeedsAction.tsx | 9 +-- .../reply-zero/NeedsReply.tsx | 11 ++-- .../reply-zero/ReplyTrackerEmails.tsx | 18 +++--- .../reply-zero/Resolved.tsx | 13 ++-- .../reply-zero/TimeRangeFilter.tsx | 0 .../reply-zero/date-filter.ts | 0 .../reply-zero/fetch-trackers.ts | 10 +-- .../reply-zero/onboarding/page.tsx | 4 +- .../reply-zero/page.tsx | 34 +++++++---- .../settings/AboutSection.tsx | 2 +- .../settings/AboutSectionForm.tsx | 2 +- .../settings/ApiKeysCreateForm.tsx | 0 .../settings/ApiKeysSection.tsx | 2 +- .../settings/DeleteSection.tsx | 2 +- .../settings/EmailUpdatesSection.tsx | 0 .../settings/LabelsSection.tsx | 2 +- .../settings/ModelSection.tsx | 0 .../settings/MultiAccountSection.tsx | 4 +- .../settings/SignatureSectionForm.tsx | 2 +- .../settings/WebhookGenerate.tsx | 2 +- .../settings/WebhookSection.tsx | 2 +- .../settings/page.tsx | 18 +++--- .../setup/page.tsx | 0 .../simple/SimpleList.tsx | 12 ++-- .../simple/SimpleModeOnboarding.tsx | 0 .../simple/SimpleProgress.tsx | 2 +- .../simple/SimpleProgressProvider.tsx | 0 .../simple/Summary.tsx | 2 +- .../simple/ViewMoreButton.tsx | 0 .../simple/categories.ts | 0 .../completed/OpenMultipleGmailButton.tsx | 0 .../simple/completed/ShareOnTwitterButton.tsx | 4 +- .../simple/completed/page.tsx | 8 +-- .../simple/layout.tsx | 2 +- .../simple/loading.tsx | 0 .../simple/page.tsx | 14 ++--- .../smart-categories/CategorizeProgress.tsx | 0 .../CategorizeWithAiButton.tsx | 6 +- .../smart-categories/CreateCategoryButton.tsx | 2 +- .../smart-categories/Uncategorized.tsx | 4 +- .../smart-categories/page.tsx | 12 ++-- .../setup/SetUpCategories.tsx | 2 +- .../setup/SmartCategoriesOnboarding.tsx | 0 .../smart-categories/setup/page.tsx | 4 +- .../stats/ActionBar.tsx | 2 +- .../stats/CombinedStatsChart.tsx | 0 .../stats/DetailedStats.tsx | 4 +- .../stats/DetailedStatsFilter.tsx | 0 .../stats/EmailActionsAnalytics.tsx | 0 .../stats/EmailAnalytics.tsx | 6 +- .../stats/EmailsToIncludeFilter.tsx | 2 +- .../stats/LoadProgress.tsx | 0 .../stats/LoadStatsButton.tsx | 0 .../stats/NewsletterModal.tsx | 8 +-- .../stats/Stats.tsx | 18 +++--- .../stats/StatsChart.tsx | 0 .../stats/StatsOnboarding.tsx | 0 .../stats/StatsSummary.tsx | 0 .../stats/page.tsx | 2 +- .../stats/params.ts | 0 .../stats/useExpanded.tsx | 0 .../usage/page.tsx | 6 +- .../usage/usage.tsx | 0 apps/web/app/(app)/layout.tsx | 2 +- .../onboarding/OnboardingBulkUnsubscriber.tsx | 2 +- .../onboarding/OnboardingColdEmailBlocker.tsx | 2 +- .../onboarding/OnboardingEmailAssistant.tsx | 4 +- apps/web/app/(app)/onboarding/page.tsx | 2 +- apps/web/app/(landing)/ai-automation/page.tsx | 2 +- .../app/(landing)/block-cold-emails/page.tsx | 2 +- .../bulk-email-unsubscriber/page.tsx | 2 +- .../app/(landing)/email-analytics/page.tsx | 2 +- apps/web/app/(landing)/page.tsx | 2 +- apps/web/app/(landing)/reply-zero-ai/page.tsx | 2 +- .../app/(landing)/welcome-upgrade/page.tsx | 2 +- .../api/ai/analyze-sender-pattern/route.ts | 13 ++-- .../app/api/ai/compose-autocomplete/route.ts | 12 ++-- apps/web/app/api/ai/summarise/controller.ts | 18 +++--- apps/web/app/api/ai/summarise/route.ts | 14 +++-- apps/web/app/api/clean/gmail/route.ts | 27 ++++---- apps/web/app/api/clean/history/route.ts | 13 ++-- apps/web/app/api/clean/route.ts | 61 ++++++++++--------- apps/web/app/api/google/labels/route.ts | 9 +-- .../api/google/messages/attachment/route.ts | 9 +-- .../app/api/google/messages/batch/route.ts | 9 +-- apps/web/app/api/google/messages/route.ts | 30 ++++----- apps/web/app/api/google/threads/[id]/route.ts | 9 +-- .../web/app/api/google/threads/basic/route.ts | 9 +-- .../web/app/api/google/threads/batch/route.ts | 8 +-- apps/web/app/api/google/threads/controller.ts | 8 +-- apps/web/app/api/google/threads/route.ts | 10 +-- .../google/webhook/process-history-item.ts | 20 +++--- .../app/api/google/webhook/process-history.ts | 15 ++--- apps/web/app/api/google/webhook/types.ts | 6 +- .../app/api/lemon-squeezy/webhook/route.ts | 2 +- apps/web/app/api/user/accounts/route.ts | 13 ++-- apps/web/app/api/user/bulk-archive/route.ts | 11 ++-- apps/web/app/api/user/categories/route.ts | 12 ++-- .../senders/batch/handle-batch-validation.ts | 2 +- .../categorize/senders/batch/handle-batch.ts | 17 +++--- apps/web/app/api/user/draft-actions/route.ts | 12 ++-- .../api/user/group/[groupId]/items/route.ts | 14 ++--- .../group/[groupId]/messages/controller.ts | 2 +- .../api/user/group/[groupId]/rules/route.ts | 8 +-- apps/web/app/api/user/group/route.ts | 14 ++--- apps/web/app/api/user/me/route.ts | 6 +- apps/web/app/api/user/no-reply/route.ts | 29 ++++++--- .../web/app/api/user/planned/history/route.ts | 10 +-- .../api/user/rules/[id]/example/controller.ts | 2 +- .../app/api/user/rules/[id]/example/route.ts | 16 ++--- apps/web/app/api/user/rules/[id]/route.ts | 18 ++++-- apps/web/app/api/user/rules/prompt/route.ts | 12 ++-- .../app/api/user/stats/newsletters/route.ts | 14 +++-- apps/web/app/api/v1/reply-tracker/route.ts | 2 +- apps/web/components/AccountSwitcher.tsx | 8 +-- apps/web/components/ActionButtons.tsx | 2 +- apps/web/components/GroupedTable.tsx | 4 +- apps/web/components/PremiumAlert.tsx | 4 +- apps/web/components/email-list/EmailList.tsx | 2 +- .../components/email-list/EmailMessage.tsx | 6 +- .../web/components/email-list/PlanActions.tsx | 2 +- apps/web/hooks/useAccounts.ts | 4 +- apps/web/prisma/schema.prisma | 40 ++++++------ apps/web/providers/ComposeModalProvider.tsx | 2 +- ...tProvider.tsx => EmailAccountProvider.tsx} | 36 ++++++----- apps/web/providers/GlobalProviders.tsx | 2 +- apps/web/providers/SWRProvider.tsx | 5 +- apps/web/store/QueueInitializer.tsx | 2 +- apps/web/utils/actions/premium.ts | 2 +- .../ai-categorize-senders.ts | 10 +-- .../ai-categorize-single-sender.ts | 10 +-- .../utils/ai/choose-rule/ai-choose-args.ts | 20 +++--- .../utils/ai/choose-rule/ai-choose-rule.ts | 38 +++++++----- .../ai-detect-recurring-pattern.ts | 16 ++--- apps/web/utils/ai/choose-rule/choose-args.ts | 14 +++-- apps/web/utils/ai/choose-rule/match-rules.ts | 17 ++++-- apps/web/utils/ai/choose-rule/run-rules.ts | 27 ++++---- .../utils/ai/clean/ai-clean-select-labels.ts | 10 +-- apps/web/utils/ai/clean/ai-clean.ts | 10 +-- .../example-matches/find-example-matches.ts | 9 ++- apps/web/utils/ai/group/create-group.ts | 18 +++--- .../knowledge/extract-from-email-history.ts | 24 ++++---- apps/web/utils/ai/knowledge/extract.ts | 24 ++++---- apps/web/utils/ai/knowledge/writing-style.ts | 14 ++--- .../utils/ai/reply/check-if-needs-reply.ts | 12 ++-- .../utils/ai/reply/draft-with-knowledge.ts | 22 +++---- apps/web/utils/ai/reply/generate-nudge.ts | 10 +-- apps/web/utils/ai/rule/create-rule.ts | 8 +-- apps/web/utils/ai/rule/diff-rules.ts | 10 +-- apps/web/utils/ai/rule/find-existing-rules.ts | 10 +-- .../ai/rule/generate-prompt-on-delete-rule.ts | 10 +-- .../ai/rule/generate-prompt-on-update-rule.ts | 10 +-- .../utils/ai/rule/generate-rules-prompt.ts | 14 ++--- apps/web/utils/ai/rule/prompt-to-rules.ts | 10 +-- apps/web/utils/ai/rule/rule-fix.ts | 12 ++-- apps/web/utils/ai/snippets/find-snippets.ts | 10 +-- .../utils/categorize/senders/categorize.ts | 27 ++++---- apps/web/utils/cold-email/is-cold-email.ts | 51 ++++++++-------- apps/web/utils/hash.ts | 4 -- apps/web/utils/llms/types.ts | 30 ++++++--- apps/web/utils/redis/account-validation.ts | 40 +++++++----- .../utils/redis/categorization-progress.ts | 26 ++++---- apps/web/utils/redis/category.ts | 48 +++++++++------ apps/web/utils/redis/clean.ts | 57 +++++++++++------ apps/web/utils/redis/clean.types.ts | 2 +- .../utils/redis/reply-tracker-analyzing.ts | 22 ++++--- .../reply-tracker/check-previous-emails.ts | 16 +++-- apps/web/utils/reply-tracker/enable.ts | 30 ++++++--- .../web/utils/reply-tracker/generate-draft.ts | 32 +++++----- apps/web/utils/reply-tracker/inbound.ts | 4 +- apps/web/utils/reply-tracker/outbound.ts | 24 ++++---- apps/web/utils/rule/rule.ts | 2 +- apps/web/utils/user/get.ts | 44 ++++++++----- apps/web/utils/user/validate.ts | 25 +++++--- 276 files changed, 1170 insertions(+), 957 deletions(-) rename apps/web/app/(app)/{[account] => [emailAccountId]}/PermissionsCheck.tsx (92%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/assess.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/AutomationOnboarding.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/BulkRunRules.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/ExecutedRulesTable.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/History.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/Pending.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/PersonaDialog.tsx (91%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/Process.tsx (93%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/ProcessResultDisplay.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/ProcessRules.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/ProcessingPromptFileDialog.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/ReportMistake.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/RuleForm.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/Rules.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/RulesPrompt.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/RulesSelect.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/SetDateDropdown.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/TestCustomEmailForm.tsx (92%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/create/examples.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/create/page.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/examples.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/group/Groups.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/group/LearnedPatterns.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/group/ViewGroup.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/group/[groupId]/examples/page.tsx (93%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/group/[groupId]/page.tsx (81%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/knowledge/KnowledgeBase.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/knowledge/KnowledgeForm.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/onboarding/CategoriesSetup.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/onboarding/completed/page.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/onboarding/draft-replies/page.tsx (96%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/onboarding/page.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/page.tsx (79%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/rule/[ruleId]/error.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/rule/[ruleId]/examples/example-list.tsx (93%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/rule/[ruleId]/examples/page.tsx (93%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/rule/[ruleId]/examples/types.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/rule/[ruleId]/page.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/automation/rule/create/page.tsx (88%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-archive/page.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/ArchiveProgress.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/BulkActions.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/BulkUnsubscribe.tsx (91%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/BulkUnsubscribeMobile.tsx (96%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/BulkUnsubscribeSection.tsx (89%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/BulkUnsubscribeSummary.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/SearchBar.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/ShortcutTooltip.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/common.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/hooks.ts (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/page.tsx (79%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/bulk-unsubscribe/types.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/ActionSelectionStep.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/CleanHistory.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/CleanInstructionsStep.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/CleanRun.tsx (83%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/CleanStats.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/ConfirmationStep.tsx (96%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/EmailFirehose.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/EmailFirehoseItem.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/IntroStep.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/PreviewBatch.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/TimeRangeStep.tsx (85%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/consts.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/helpers.ts (72%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/history/page.tsx (91%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/loading.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/onboarding/page.tsx (74%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/page.tsx (63%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/run/page.tsx (81%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/types.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/useEmailStream.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/useSkipSettings.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/clean/useStep.tsx (85%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/cold-email-blocker/ColdEmailList.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/cold-email-blocker/ColdEmailPromptForm.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/cold-email-blocker/ColdEmailRejected.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/cold-email-blocker/ColdEmailSettings.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/cold-email-blocker/ColdEmailTest.tsx (82%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/cold-email-blocker/TestRules.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/cold-email-blocker/page.tsx (77%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/compose/ComposeEmailForm.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/compose/ComposeEmailFormLazy.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/compose/page.tsx (73%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/debug/drafts/page.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/debug/learned/page.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/debug/page.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/early-access/EarlyAccessFeatures.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/early-access/page.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/mail/BetaBanner.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/mail/page.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/no-reply/page.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/permissions/consent/page.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/permissions/error/page.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/premium/PremiumModal.tsx (89%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/premium/Pricing.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/premium/config.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/premium/page.tsx (72%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/AwaitingReply.tsx (80%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/EnableReplyTracker.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/NeedsAction.tsx (84%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/NeedsReply.tsx (80%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/ReplyTrackerEmails.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/Resolved.tsx (86%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/TimeRangeFilter.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/date-filter.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/fetch-trackers.ts (89%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/onboarding/page.tsx (76%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/reply-zero/page.tsx (83%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/AboutSection.tsx (58%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/AboutSectionForm.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/ApiKeysCreateForm.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/ApiKeysSection.tsx (96%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/DeleteSection.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/EmailUpdatesSection.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/LabelsSection.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/ModelSection.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/MultiAccountSection.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/SignatureSectionForm.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/WebhookGenerate.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/WebhookSection.tsx (89%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/settings/page.tsx (61%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/setup/page.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/SimpleList.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/SimpleModeOnboarding.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/SimpleProgress.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/SimpleProgressProvider.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/Summary.tsx (91%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/ViewMoreButton.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/categories.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/completed/OpenMultipleGmailButton.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/completed/ShareOnTwitterButton.tsx (83%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/completed/page.tsx (87%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/layout.tsx (62%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/loading.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/simple/page.tsx (86%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/CategorizeProgress.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/CategorizeWithAiButton.tsx (92%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/CreateCategoryButton.tsx (99%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/Uncategorized.tsx (97%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/page.tsx (88%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/setup/SetUpCategories.tsx (98%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/setup/SmartCategoriesOnboarding.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/smart-categories/setup/page.tsx (71%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/ActionBar.tsx (96%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/CombinedStatsChart.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/DetailedStats.tsx (96%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/DetailedStatsFilter.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/EmailActionsAnalytics.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/EmailAnalytics.tsx (93%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/EmailsToIncludeFilter.tsx (94%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/LoadProgress.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/LoadStatsButton.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/NewsletterModal.tsx (95%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/Stats.tsx (79%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/StatsChart.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/StatsOnboarding.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/StatsSummary.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/page.tsx (77%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/params.ts (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/stats/useExpanded.tsx (100%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/usage/page.tsx (79%) rename apps/web/app/(app)/{[account] => [emailAccountId]}/usage/usage.tsx (100%) rename apps/web/providers/{AccountProvider.tsx => EmailAccountProvider.tsx} (55%) delete mode 100644 apps/web/utils/hash.ts diff --git a/apps/web/app/(app)/[account]/PermissionsCheck.tsx b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx similarity index 92% rename from apps/web/app/(app)/[account]/PermissionsCheck.tsx rename to apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx index 35459b289..731d4a61a 100644 --- a/apps/web/app/(app)/[account]/PermissionsCheck.tsx +++ b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { checkPermissionsAction } from "@/utils/actions/permissions"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; const permissionsChecked: Record = {}; diff --git a/apps/web/app/(app)/[account]/assess.tsx b/apps/web/app/(app)/[emailAccountId]/assess.tsx similarity index 94% rename from apps/web/app/(app)/[account]/assess.tsx rename to apps/web/app/(app)/[emailAccountId]/assess.tsx index 8b2c296da..828fca50e 100644 --- a/apps/web/app/(app)/[account]/assess.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assess.tsx @@ -7,7 +7,7 @@ import { analyzeWritingStyleAction, assessAction, } from "@/utils/actions/assess"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function AssessUser() { const { email } = useAccount(); diff --git a/apps/web/app/(app)/[account]/automation/AutomationOnboarding.tsx b/apps/web/app/(app)/[emailAccountId]/automation/AutomationOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/AutomationOnboarding.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/AutomationOnboarding.tsx diff --git a/apps/web/app/(app)/[account]/automation/BulkRunRules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/BulkRunRules.tsx similarity index 98% rename from apps/web/app/(app)/[account]/automation/BulkRunRules.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/BulkRunRules.tsx index 34ca64a19..3d49c5409 100644 --- a/apps/web/app/(app)/[account]/automation/BulkRunRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/BulkRunRules.tsx @@ -11,7 +11,7 @@ import { LoadingContent } from "@/components/LoadingContent"; import { runAiRules } from "@/utils/queue/email-actions"; import { sleep } from "@/utils/sleep"; import { PremiumAlertWithData, usePremium } from "@/components/PremiumAlert"; -import { SetDateDropdown } from "@/app/(app)/[account]/automation/SetDateDropdown"; +import { SetDateDropdown } from "@/app/(app)/[emailAccountId]/automation/SetDateDropdown"; import { dateToSeconds } from "@/utils/date"; import { useThreads } from "@/hooks/useThreads"; import { useAiQueueState } from "@/store/ai-queue"; diff --git a/apps/web/app/(app)/[account]/automation/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ExecutedRulesTable.tsx similarity index 98% rename from apps/web/app/(app)/[account]/automation/ExecutedRulesTable.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/ExecutedRulesTable.tsx index 857af25c5..686264acf 100644 --- a/apps/web/app/(app)/[account]/automation/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ExecutedRulesTable.tsx @@ -12,7 +12,7 @@ import { Badge } from "@/components/Badge"; import { Button } from "@/components/ui/button"; import { conditionsToString, conditionTypesToString } from "@/utils/condition"; import { MessageText } from "@/components/Typography"; -import { ReportMistake } from "@/app/(app)/[account]/automation/ReportMistake"; +import { ReportMistake } from "@/app/(app)/[emailAccountId]/automation/ReportMistake"; import type { ParsedMessage } from "@/utils/types"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { ExecutedRuleStatus } from "@prisma/client"; diff --git a/apps/web/app/(app)/[account]/automation/History.tsx b/apps/web/app/(app)/[emailAccountId]/automation/History.tsx similarity index 94% rename from apps/web/app/(app)/[account]/automation/History.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/History.tsx index c50d11ed5..799395b3e 100644 --- a/apps/web/app/(app)/[account]/automation/History.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/History.tsx @@ -19,11 +19,11 @@ import { DateCell, EmailCell, RuleCell, -} from "@/app/(app)/[account]/automation/ExecutedRulesTable"; +} from "@/app/(app)/[emailAccountId]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { Badge } from "@/components/Badge"; -import { RulesSelect } from "@/app/(app)/[account]/automation/RulesSelect"; -import { useAccount } from "@/providers/AccountProvider"; +import { RulesSelect } from "@/app/(app)/[emailAccountId]/automation/RulesSelect"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function History() { const [page] = useQueryState("page", parseAsInteger.withDefault(1)); diff --git a/apps/web/app/(app)/[account]/automation/Pending.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Pending.tsx similarity index 97% rename from apps/web/app/(app)/[account]/automation/Pending.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/Pending.tsx index 6bd7b9e93..e6734d0b6 100644 --- a/apps/web/app/(app)/[account]/automation/Pending.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Pending.tsx @@ -24,12 +24,12 @@ import { ActionItemsCell, EmailCell, RuleCell, -} from "@/app/(app)/[account]/automation/ExecutedRulesTable"; +} from "@/app/(app)/[emailAccountId]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { Checkbox } from "@/components/Checkbox"; import { useToggleSelect } from "@/hooks/useToggleSelect"; -import { RulesSelect } from "@/app/(app)/[account]/automation/RulesSelect"; -import { useAccount } from "@/providers/AccountProvider"; +import { RulesSelect } from "@/app/(app)/[emailAccountId]/automation/RulesSelect"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function Pending() { const [page] = useQueryState("page", parseAsInteger.withDefault(1)); diff --git a/apps/web/app/(app)/[account]/automation/PersonaDialog.tsx b/apps/web/app/(app)/[emailAccountId]/automation/PersonaDialog.tsx similarity index 91% rename from apps/web/app/(app)/[account]/automation/PersonaDialog.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/PersonaDialog.tsx index 7412e5319..6f98dc237 100644 --- a/apps/web/app/(app)/[account]/automation/PersonaDialog.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/PersonaDialog.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { ButtonList } from "@/components/ButtonList"; -import { personas } from "@/app/(app)/[account]/automation/examples"; +import { personas } from "@/app/(app)/[emailAccountId]/automation/examples"; export function PersonaDialog({ isOpen, diff --git a/apps/web/app/(app)/[account]/automation/Process.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Process.tsx similarity index 93% rename from apps/web/app/(app)/[account]/automation/Process.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/Process.tsx index 7c0cefbe4..c1ca3bec2 100644 --- a/apps/web/app/(app)/[account]/automation/Process.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Process.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryState } from "nuqs"; -import { ProcessRulesContent } from "@/app/(app)/[account]/automation/ProcessRules"; +import { ProcessRulesContent } from "@/app/(app)/[emailAccountId]/automation/ProcessRules"; import { Toggle } from "@/components/Toggle"; import { Card, diff --git a/apps/web/app/(app)/[account]/automation/ProcessResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ProcessResultDisplay.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/ProcessResultDisplay.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/ProcessResultDisplay.tsx diff --git a/apps/web/app/(app)/[account]/automation/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx similarity index 95% rename from apps/web/app/(app)/[account]/automation/ProcessRules.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx index 86b7c7866..004b45b68 100644 --- a/apps/web/app/(app)/[account]/automation/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx @@ -24,7 +24,7 @@ import { Table, TableBody, TableRow, TableCell } from "@/components/ui/table"; import { CardContent } from "@/components/ui/card"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; import { SearchForm } from "@/components/SearchForm"; -import { ReportMistake } from "@/app/(app)/[account]/automation/ReportMistake"; +import { ReportMistake } from "@/app/(app)/[emailAccountId]/automation/ReportMistake"; import { Badge } from "@/components/Badge"; import { isAIRule, @@ -32,12 +32,12 @@ import { isGroupRule, isStaticRule, } from "@/utils/condition"; -import { BulkRunRules } from "@/app/(app)/[account]/automation/BulkRunRules"; +import { BulkRunRules } from "@/app/(app)/[emailAccountId]/automation/BulkRunRules"; import { cn } from "@/utils"; -import { TestCustomEmailForm } from "@/app/(app)/[account]/automation/TestCustomEmailForm"; -import { ProcessResultDisplay } from "@/app/(app)/[account]/automation/ProcessResultDisplay"; +import { TestCustomEmailForm } from "@/app/(app)/[emailAccountId]/automation/TestCustomEmailForm"; +import { ProcessResultDisplay } from "@/app/(app)/[emailAccountId]/automation/ProcessResultDisplay"; import { Tooltip } from "@/components/Tooltip"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; type Message = MessagesResponse["messages"][number]; diff --git a/apps/web/app/(app)/[account]/automation/ProcessingPromptFileDialog.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ProcessingPromptFileDialog.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/ProcessingPromptFileDialog.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/ProcessingPromptFileDialog.tsx diff --git a/apps/web/app/(app)/[account]/automation/ReportMistake.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx similarity index 99% rename from apps/web/app/(app)/[account]/automation/ReportMistake.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx index c04bf962b..d3f0320fb 100644 --- a/apps/web/app/(app)/[account]/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx @@ -44,7 +44,7 @@ import { updateRuleInstructionsAction } from "@/utils/actions/rule"; import { Separator } from "@/components/ui/separator"; import { SectionDescription } from "@/components/Typography"; import { Badge } from "@/components/Badge"; -import { ProcessResultDisplay } from "@/app/(app)/[account]/automation/ProcessResultDisplay"; +import { ProcessResultDisplay } from "@/app/(app)/[emailAccountId]/automation/ProcessResultDisplay"; import { isReplyInThread } from "@/utils/thread"; import { isAIRule, isGroupRule, isStaticRule } from "@/utils/condition"; import { Loading, LoadingMiniSpinner } from "@/components/Loading"; @@ -55,13 +55,13 @@ import { } from "@/utils/actions/group"; import { useRules } from "@/hooks/useRules"; import type { CategoryMatch, GroupMatch } from "@/utils/ai/choose-rule/types"; -import { GroupItemDisplay } from "@/app/(app)/[account]/automation/group/ViewGroup"; +import { GroupItemDisplay } from "@/app/(app)/[emailAccountId]/automation/group/ViewGroup"; import { cn } from "@/utils"; import { useCategories } from "@/hooks/useCategories"; import { CategorySelect } from "@/components/CategorySelect"; import { useModal } from "@/hooks/useModal"; import { ConditionType } from "@/utils/config"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; type ReportMistakeView = "select-expected-rule" | "ai-fix" | "manual-fix"; diff --git a/apps/web/app/(app)/[account]/automation/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx similarity index 99% rename from apps/web/app/(app)/[account]/automation/RuleForm.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx index f9e7818d4..300aa82ef 100644 --- a/apps/web/app/(app)/[account]/automation/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx @@ -61,12 +61,12 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { LearnedPatterns } from "@/app/(app)/[account]/automation/group/LearnedPatterns"; +import { LearnedPatterns } from "@/app/(app)/[emailAccountId]/automation/group/LearnedPatterns"; import { Tooltip } from "@/components/Tooltip"; import { createGroupAction } from "@/utils/actions/group"; import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; import { Badge } from "@/components/Badge"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function RuleForm({ rule, }: { diff --git a/apps/web/app/(app)/[account]/automation/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx similarity index 98% rename from apps/web/app/(app)/[account]/automation/Rules.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx index 9910fa562..db94b2fb1 100644 --- a/apps/web/app/(app)/[account]/automation/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx @@ -42,9 +42,9 @@ import { Tooltip } from "@/components/Tooltip"; import type { RiskLevel } from "@/utils/risk"; import { useRules } from "@/hooks/useRules"; import { ActionType } from "@prisma/client"; -import { ThreadsExplanation } from "@/app/(app)/[account]/automation/RuleForm"; +import { ThreadsExplanation } from "@/app/(app)/[emailAccountId]/automation/RuleForm"; import { useAction } from "next-safe-action/hooks"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function Rules() { const { data, isLoading, error, mutate } = useRules(); diff --git a/apps/web/app/(app)/[account]/automation/RulesPrompt.tsx b/apps/web/app/(app)/[emailAccountId]/automation/RulesPrompt.tsx similarity index 95% rename from apps/web/app/(app)/[account]/automation/RulesPrompt.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/RulesPrompt.tsx index 63fc59f9b..9ba0e95df 100644 --- a/apps/web/app/(app)/[account]/automation/RulesPrompt.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/RulesPrompt.tsx @@ -25,13 +25,13 @@ import type { RulesPromptResponse } from "@/app/api/user/rules/prompt/route"; import { LoadingContent } from "@/components/LoadingContent"; import { Tooltip } from "@/components/Tooltip"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; -import { AutomationOnboarding } from "@/app/(app)/[account]/automation/AutomationOnboarding"; -import { examplePrompts, personas } from "@/app/(app)/[account]/automation/examples"; -import { PersonaDialog } from "@/app/(app)/[account]/automation/PersonaDialog"; +import { AutomationOnboarding } from "@/app/(app)/[emailAccountId]/automation/AutomationOnboarding"; +import { examplePrompts, personas } from "@/app/(app)/[emailAccountId]/automation/examples"; +import { PersonaDialog } from "@/app/(app)/[emailAccountId]/automation/PersonaDialog"; import { useModal } from "@/hooks/useModal"; -import { ProcessingPromptFileDialog } from "@/app/(app)/[account]/automation/ProcessingPromptFileDialog"; +import { ProcessingPromptFileDialog } from "@/app/(app)/[emailAccountId]/automation/ProcessingPromptFileDialog"; import { AlertBasic } from "@/components/Alert"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function RulesPrompt() { const { data, isLoading, error, mutate } = useSWR< diff --git a/apps/web/app/(app)/[account]/automation/RulesSelect.tsx b/apps/web/app/(app)/[emailAccountId]/automation/RulesSelect.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/RulesSelect.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/RulesSelect.tsx diff --git a/apps/web/app/(app)/[account]/automation/SetDateDropdown.tsx b/apps/web/app/(app)/[emailAccountId]/automation/SetDateDropdown.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/SetDateDropdown.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/SetDateDropdown.tsx diff --git a/apps/web/app/(app)/[account]/automation/TestCustomEmailForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/TestCustomEmailForm.tsx similarity index 92% rename from apps/web/app/(app)/[account]/automation/TestCustomEmailForm.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/TestCustomEmailForm.tsx index 785c4d7dc..35ee06510 100644 --- a/apps/web/app/(app)/[account]/automation/TestCustomEmailForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/TestCustomEmailForm.tsx @@ -8,13 +8,13 @@ import { Input } from "@/components/Input"; import { toastError } from "@/components/Toast"; import { testAiCustomContentAction } from "@/utils/actions/ai-rule"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; -import { ProcessResultDisplay } from "@/app/(app)/[account]/automation/ProcessResultDisplay"; +import { ProcessResultDisplay } from "@/app/(app)/[emailAccountId]/automation/ProcessResultDisplay"; import { testAiCustomContentBody, type TestAiCustomContentBody, } from "@/utils/actions/ai-rule.validation"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export const TestCustomEmailForm = () => { const [testResult, setTestResult] = useState(); diff --git a/apps/web/app/(app)/[account]/automation/create/examples.tsx b/apps/web/app/(app)/[emailAccountId]/automation/create/examples.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/create/examples.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/create/examples.tsx diff --git a/apps/web/app/(app)/[account]/automation/create/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx similarity index 97% rename from apps/web/app/(app)/[account]/automation/create/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx index 981923404..a9f507f66 100644 --- a/apps/web/app/(app)/[account]/automation/create/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx @@ -15,8 +15,8 @@ import { import { Button } from "@/components/ui/button"; import { createAutomationAction } from "@/utils/actions/ai-rule"; import { toastError } from "@/components/Toast"; -import { examples } from "@/app/(app)/[account]/automation/create/examples"; -import { useAccount } from "@/providers/AccountProvider"; +import { examples } from "@/app/(app)/[emailAccountId]/automation/create/examples"; +import { useAccount } from "@/providers/EmailAccountProvider"; import type { CreateAutomationBody } from "@/utils/actions/ai-rule.validation"; // not in use anymore diff --git a/apps/web/app/(app)/[account]/automation/examples.ts b/apps/web/app/(app)/[emailAccountId]/automation/examples.ts similarity index 100% rename from apps/web/app/(app)/[account]/automation/examples.ts rename to apps/web/app/(app)/[emailAccountId]/automation/examples.ts diff --git a/apps/web/app/(app)/[account]/automation/group/Groups.tsx b/apps/web/app/(app)/[emailAccountId]/automation/group/Groups.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/group/Groups.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/group/Groups.tsx diff --git a/apps/web/app/(app)/[account]/automation/group/LearnedPatterns.tsx b/apps/web/app/(app)/[emailAccountId]/automation/group/LearnedPatterns.tsx similarity index 94% rename from apps/web/app/(app)/[account]/automation/group/LearnedPatterns.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/group/LearnedPatterns.tsx index 78a54cdf4..0341149a1 100644 --- a/apps/web/app/(app)/[account]/automation/group/LearnedPatterns.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/group/LearnedPatterns.tsx @@ -7,7 +7,7 @@ import { CollapsibleTrigger, CollapsibleContent, } from "@/components/ui/collapsible"; -import { ViewGroup } from "@/app/(app)/[account]/automation/group/ViewGroup"; +import { ViewGroup } from "@/app/(app)/[emailAccountId]/automation/group/ViewGroup"; export function LearnedPatterns({ groupId }: { groupId: string }) { const [isOpen, setIsOpen] = useState(false); diff --git a/apps/web/app/(app)/[account]/automation/group/ViewGroup.tsx b/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx similarity index 99% rename from apps/web/app/(app)/[account]/automation/group/ViewGroup.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx index 623b369f5..7b5324009 100644 --- a/apps/web/app/(app)/[account]/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx @@ -41,7 +41,7 @@ import { import { Badge } from "@/components/ui/badge"; import { formatShortDate } from "@/utils/date"; import { Tooltip } from "@/components/Tooltip"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ViewGroup({ groupId }: { groupId: string }) { const { data, isLoading, error, mutate } = useSWR( diff --git a/apps/web/app/(app)/[account]/automation/group/[groupId]/examples/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/group/[groupId]/examples/page.tsx similarity index 93% rename from apps/web/app/(app)/[account]/automation/group/[groupId]/examples/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/group/[groupId]/examples/page.tsx index 04096398c..85b6976e5 100644 --- a/apps/web/app/(app)/[account]/automation/group/[groupId]/examples/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/group/[groupId]/examples/page.tsx @@ -4,7 +4,7 @@ import useSWR from "swr"; import { use } from "react"; import groupBy from "lodash/groupBy"; import { TopSection } from "@/components/TopSection"; -import { ExampleList } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list"; +import { ExampleList } from "@/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list"; import type { GroupEmailsResponse } from "@/app/api/user/group/[groupId]/messages/controller"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/[account]/automation/group/[groupId]/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/group/[groupId]/page.tsx similarity index 81% rename from apps/web/app/(app)/[account]/automation/group/[groupId]/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/group/[groupId]/page.tsx index 1a5c06457..efadc7b69 100644 --- a/apps/web/app/(app)/[account]/automation/group/[groupId]/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/group/[groupId]/page.tsx @@ -1,4 +1,4 @@ -import { ViewGroup } from "@/app/(app)/[account]/automation/group/ViewGroup"; +import { ViewGroup } from "@/app/(app)/[emailAccountId]/automation/group/ViewGroup"; import { Container } from "@/components/Container"; // Not in use anymore. Could delete this. diff --git a/apps/web/app/(app)/[account]/automation/knowledge/KnowledgeBase.tsx b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx similarity index 97% rename from apps/web/app/(app)/[account]/automation/knowledge/KnowledgeBase.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx index b8e99f459..1edb0f3c6 100644 --- a/apps/web/app/(app)/[account]/automation/knowledge/KnowledgeBase.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx @@ -27,8 +27,8 @@ import type { GetKnowledgeResponse } from "@/app/api/knowledge/route"; import { formatDateSimple } from "@/utils/date"; import type { Knowledge } from "@prisma/client"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { KnowledgeForm } from "@/app/(app)/[account]/automation/knowledge/KnowledgeForm"; -import { useAccount } from "@/providers/AccountProvider"; +import { KnowledgeForm } from "@/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function KnowledgeBase() { const { email } = useAccount(); diff --git a/apps/web/app/(app)/[account]/automation/knowledge/KnowledgeForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx similarity index 98% rename from apps/web/app/(app)/[account]/automation/knowledge/KnowledgeForm.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx index def8aa2a9..fc173f19e 100644 --- a/apps/web/app/(app)/[account]/automation/knowledge/KnowledgeForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx @@ -22,7 +22,7 @@ import type { Knowledge } from "@prisma/client"; import { Tiptap, type TiptapHandle } from "@/components/editor/Tiptap"; import { Label } from "@/components/ui/label"; import { cn } from "@/utils"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function KnowledgeForm({ closeDialog, diff --git a/apps/web/app/(app)/[account]/automation/onboarding/CategoriesSetup.tsx b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/CategoriesSetup.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/onboarding/CategoriesSetup.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/onboarding/CategoriesSetup.tsx diff --git a/apps/web/app/(app)/[account]/automation/onboarding/completed/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/completed/page.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/onboarding/completed/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/onboarding/completed/page.tsx diff --git a/apps/web/app/(app)/[account]/automation/onboarding/draft-replies/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/draft-replies/page.tsx similarity index 96% rename from apps/web/app/(app)/[account]/automation/onboarding/draft-replies/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/onboarding/draft-replies/page.tsx index 6b6712e2d..c6393382a 100644 --- a/apps/web/app/(app)/[account]/automation/onboarding/draft-replies/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/draft-replies/page.tsx @@ -11,7 +11,7 @@ import { ASSISTANT_ONBOARDING_COOKIE, markOnboardingAsCompleted, } from "@/utils/cookies"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export default function DraftRepliesPage() { const router = useRouter(); diff --git a/apps/web/app/(app)/[account]/automation/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/page.tsx similarity index 98% rename from apps/web/app/(app)/[account]/automation/onboarding/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/onboarding/page.tsx index 0f3837421..d73c1753e 100644 --- a/apps/web/app/(app)/[account]/automation/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/page.tsx @@ -12,7 +12,7 @@ import type { CategoryAction } from "@/utils/actions/rule.validation"; export default async function OnboardingPage({ params, }: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; }) { const { account } = await params; const defaultValues = await getUserPreferences({ accountId: account }); diff --git a/apps/web/app/(app)/[account]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx similarity index 79% rename from apps/web/app/(app)/[account]/automation/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/page.tsx index e2032f721..9284af7c0 100644 --- a/apps/web/app/(app)/[account]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -3,17 +3,16 @@ import Link from "next/link"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import prisma from "@/utils/prisma"; -import { History } from "@/app/(app)/[account]/automation/History"; -import { Pending } from "@/app/(app)/[account]/automation/Pending"; +import { History } from "@/app/(app)/[emailAccountId]/automation/History"; +import { Pending } from "@/app/(app)/[emailAccountId]/automation/Pending"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { Rules } from "@/app/(app)/[account]/automation/Rules"; -import { Process } from "@/app/(app)/[account]/automation/Process"; -import { KnowledgeBase } from "@/app/(app)/[account]/automation/knowledge/KnowledgeBase"; -import { Groups } from "@/app/(app)/[account]/automation/group/Groups"; -import { RulesPrompt } from "@/app/(app)/[account]/automation/RulesPrompt"; +import { Rules } from "@/app/(app)/[emailAccountId]/automation/Rules"; +import { Process } from "@/app/(app)/[emailAccountId]/automation/Process"; +import { KnowledgeBase } from "@/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase"; +import { Groups } from "@/app/(app)/[emailAccountId]/automation/group/Groups"; +import { RulesPrompt } from "@/app/(app)/[emailAccountId]/automation/RulesPrompt"; import { OnboardingModal } from "@/components/OnboardingModal"; -import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; import { TabsToolbar } from "@/components/TabsToolbar"; import { GmailProvider } from "@/providers/GmailProvider"; import { ASSISTANT_ONBOARDING_COOKIE } from "@/utils/cookies"; @@ -21,10 +20,12 @@ import { Button } from "@/components/ui/button"; export const maxDuration = 300; // Applies to the actions -export default async function AutomationPage() { - const session = await auth(); - const email = session?.user.email; - if (!email) redirect("/login"); +export default async function AutomationPage({ + params, +}: { + params: Promise<{ emailAccountId: string }>; +}) { + const { account } = await params; // onboarding redirect const cookieStore = await cookies(); @@ -33,7 +34,7 @@ export default async function AutomationPage() { if (!viewedOnboarding) { const hasRule = await prisma.rule.findFirst({ - where: { emailAccountId: email }, + where: { emailAccount: { accountId: account } }, select: { id: true }, }); diff --git a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/error.tsx b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/error.tsx similarity index 100% rename from apps/web/app/(app)/[account]/automation/rule/[ruleId]/error.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/error.tsx diff --git a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list.tsx b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list.tsx similarity index 93% rename from apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list.tsx index 25aec1a0e..1c11d4584 100644 --- a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list.tsx @@ -6,9 +6,9 @@ import type { Dictionary } from "lodash"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { deleteGroupItemAction } from "@/utils/actions/group"; -import type { MessageWithGroupItem } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/types"; +import type { MessageWithGroupItem } from "@/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/types"; import { toastError } from "@/components/Toast"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ExampleList({ groupedBySenders, diff --git a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/page.tsx similarity index 93% rename from apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/page.tsx index 04a91d35e..9210a865e 100644 --- a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/page.tsx @@ -6,7 +6,7 @@ import useSWR from "swr"; import groupBy from "lodash/groupBy"; import { TopSection } from "@/components/TopSection"; import { Button } from "@/components/ui/button"; -import { ExampleList } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/example-list"; +import { ExampleList } from "@/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list"; import type { ExamplesResponse } from "@/app/api/user/rules/[id]/example/route"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/types.ts b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/types.ts similarity index 100% rename from apps/web/app/(app)/[account]/automation/rule/[ruleId]/examples/types.ts rename to apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/types.ts diff --git a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/page.tsx similarity index 95% rename from apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/page.tsx index 739065f79..21ddcc3cf 100644 --- a/apps/web/app/(app)/[account]/automation/rule/[ruleId]/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/page.tsx @@ -1,5 +1,5 @@ import prisma from "@/utils/prisma"; -import { RuleForm } from "@/app/(app)/[account]/automation/RuleForm"; +import { RuleForm } from "@/app/(app)/[emailAccountId]/automation/RuleForm"; import { TopSection } from "@/components/TopSection"; import { hasVariables } from "@/utils/template"; import { getConditions } from "@/utils/condition"; diff --git a/apps/web/app/(app)/[account]/automation/rule/create/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/rule/create/page.tsx similarity index 88% rename from apps/web/app/(app)/[account]/automation/rule/create/page.tsx rename to apps/web/app/(app)/[emailAccountId]/automation/rule/create/page.tsx index caf55b8e7..9880cc844 100644 --- a/apps/web/app/(app)/[account]/automation/rule/create/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/rule/create/page.tsx @@ -1,5 +1,5 @@ -import { RuleForm } from "@/app/(app)/[account]/automation/RuleForm"; -import { examples } from "@/app/(app)/[account]/automation/create/examples"; +import { RuleForm } from "@/app/(app)/[emailAccountId]/automation/RuleForm"; +import { examples } from "@/app/(app)/[emailAccountId]/automation/create/examples"; import { getEmptyCondition } from "@/utils/condition"; import { ActionType } from "@prisma/client"; import type { CoreConditionType } from "@/utils/config"; diff --git a/apps/web/app/(app)/[account]/bulk-archive/page.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-archive/page.tsx similarity index 100% rename from apps/web/app/(app)/[account]/bulk-archive/page.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-archive/page.tsx diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/ArchiveProgress.tsx similarity index 100% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/ArchiveProgress.tsx diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkActions.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions.tsx similarity index 95% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/BulkActions.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions.tsx index d07de58fa..e2a0834f6 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkActions.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions.tsx @@ -11,10 +11,10 @@ import { useBulkAutoArchive, useBulkArchive, useBulkDelete, -} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; import { Button } from "@/components/ui/button"; -import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; export function BulkActions({ selected, diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribe.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribe.tsx similarity index 91% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribe.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribe.tsx index 74124fa11..424ea204b 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribe.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribe.tsx @@ -4,9 +4,9 @@ import subDays from "date-fns/subDays"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useWindowSize } from "usehooks-ts"; import type { DateRange } from "react-day-picker"; -import { BulkUnsubscribeSection } from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection"; -import { LoadStatsButton } from "@/app/(app)/[account]/stats/LoadStatsButton"; -import { ActionBar } from "@/app/(app)/[account]/stats/ActionBar"; +import { BulkUnsubscribeSection } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection"; +import { LoadStatsButton } from "@/app/(app)/[emailAccountId]/stats/LoadStatsButton"; +import { ActionBar } from "@/app/(app)/[emailAccountId]/stats/ActionBar"; import { useStatLoader } from "@/providers/StatLoaderProvider"; import { OnboardingModal } from "@/components/OnboardingModal"; import { TextLink } from "@/components/Typography"; diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx similarity index 95% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index e6f5e4f5f..8b62ee995 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -10,8 +10,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { ActionCell, HeaderButton } from "@/app/(app)/[account]/bulk-unsubscribe/common"; -import type { RowProps } from "@/app/(app)/[account]/bulk-unsubscribe/types"; +import { ActionCell, HeaderButton } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/common"; +import type { RowProps } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import { Checkbox } from "@/components/Checkbox"; export function BulkUnsubscribeDesktop({ diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx similarity index 96% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx index efd0e37aa..c1bdc8e07 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx @@ -13,7 +13,7 @@ import { useUnsubscribe, useApproveButton, useArchiveAll, -} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks"; import { Card, CardContent, @@ -22,7 +22,7 @@ import { CardTitle, } from "@/components/ui/card"; import { extractEmailAddress, extractNameFromEmail } from "@/utils/email"; -import type { RowProps } from "@/app/(app)/[account]/bulk-unsubscribe/types"; +import type { RowProps } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import { Button } from "@/components/ui/button"; import { ButtonLoader } from "@/components/Loading"; import { NewsletterStatus } from "@prisma/client"; diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx similarity index 89% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index a977a4031..ed608c4e7 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -13,36 +13,36 @@ import type { NewsletterStatsQuery, NewsletterStatsResponse, } from "@/app/api/user/stats/newsletters/route"; -import { useExpanded } from "@/app/(app)/[account]/stats/useExpanded"; -import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; -import { NewsletterModal } from "@/app/(app)/[account]/stats/NewsletterModal"; -import { useEmailsToIncludeFilter } from "@/app/(app)/[account]/stats/EmailsToIncludeFilter"; -import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; +import { useExpanded } from "@/app/(app)/[emailAccountId]/stats/useExpanded"; +import { getDateRangeParams } from "@/app/(app)/[emailAccountId]/stats/params"; +import { NewsletterModal } from "@/app/(app)/[emailAccountId]/stats/NewsletterModal"; +import { useEmailsToIncludeFilter } from "@/app/(app)/[emailAccountId]/stats/EmailsToIncludeFilter"; +import { DetailedStatsFilter } from "@/app/(app)/[emailAccountId]/stats/DetailedStatsFilter"; import { usePremium } from "@/components/PremiumAlert"; import { useNewsletterFilter, useBulkUnsubscribeShortcuts, -} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks"; import { useStatLoader } from "@/providers/StatLoaderProvider"; -import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; import { useLabels } from "@/hooks/useLabels"; import { BulkUnsubscribeMobile, BulkUnsubscribeRowMobile, -} from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeMobile"; +} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile"; import { BulkUnsubscribeDesktop, BulkUnsubscribeRowDesktop, -} from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeDesktop"; +} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop"; import { Card } from "@/components/ui/card"; -import { ShortcutTooltip } from "@/app/(app)/[account]/bulk-unsubscribe/ShortcutTooltip"; -import { SearchBar } from "@/app/(app)/[account]/bulk-unsubscribe/SearchBar"; +import { ShortcutTooltip } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ShortcutTooltip"; +import { SearchBar } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar"; import { useToggleSelect } from "@/hooks/useToggleSelect"; -import { BulkActions } from "@/app/(app)/[account]/bulk-unsubscribe/BulkActions"; -import { ArchiveProgress } from "@/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress"; +import { BulkActions } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions"; +import { ArchiveProgress } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ArchiveProgress"; import { ClientOnly } from "@/components/ClientOnly"; import { Toggle } from "@/components/Toggle"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; type Newsletter = NewsletterStatsResponse["newsletters"][number]; diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSummary.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSummary.tsx similarity index 100% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSummary.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSummary.tsx diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/SearchBar.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar.tsx similarity index 100% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/SearchBar.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar.tsx diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/ShortcutTooltip.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/ShortcutTooltip.tsx similarity index 100% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/ShortcutTooltip.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/ShortcutTooltip.tsx diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/common.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx similarity index 99% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/common.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx index b017a1355..b5de3395e 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/common.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx @@ -41,14 +41,14 @@ import { NewsletterStatus } from "@prisma/client"; import { toastError, toastSuccess } from "@/components/Toast"; import { createFilterAction } from "@/utils/actions/mail"; import { getGmailSearchUrl } from "@/utils/url"; -import type { Row } from "@/app/(app)/[account]/bulk-unsubscribe/types"; +import type { Row } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import { useUnsubscribe, useAutoArchive, useApproveButton, useArchiveAll, useDeleteAllFromSender, -} from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; +} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks"; import { LabelsSubMenu } from "@/components/LabelsSubMenu"; import type { UserLabel } from "@/hooks/useLabels"; diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts similarity index 99% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/hooks.ts rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts index da9d031f4..837ae10f2 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/hooks.ts +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts @@ -11,7 +11,7 @@ import { cleanUnsubscribeLink } from "@/utils/parse/parseHtml.client"; import { captureException } from "@/utils/error"; import { addToArchiveSenderQueue } from "@/store/archive-sender-queue"; import { deleteEmails } from "@/store/archive-queue"; -import type { Row } from "@/app/(app)/[account]/bulk-unsubscribe/types"; +import type { Row } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import type { GetThreadsResponse } from "@/app/api/google/threads/basic/route"; import { isDefined } from "@/utils/types"; diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/page.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/page.tsx similarity index 79% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/page.tsx rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/page.tsx index 573596e9b..be2bddb6f 100644 --- a/apps/web/app/(app)/[account]/bulk-unsubscribe/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/page.tsx @@ -1,4 +1,4 @@ -import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; import { BulkUnsubscribe } from "./BulkUnsubscribe"; import { checkAndRedirectForUpgrade } from "@/utils/premium/check-and-redirect-for-upgrade"; diff --git a/apps/web/app/(app)/[account]/bulk-unsubscribe/types.ts b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/types.ts similarity index 100% rename from apps/web/app/(app)/[account]/bulk-unsubscribe/types.ts rename to apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/types.ts diff --git a/apps/web/app/(app)/[account]/clean/ActionSelectionStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/ActionSelectionStep.tsx similarity index 94% rename from apps/web/app/(app)/[account]/clean/ActionSelectionStep.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/ActionSelectionStep.tsx index a230ae860..7d3b79d5f 100644 --- a/apps/web/app/(app)/[account]/clean/ActionSelectionStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/ActionSelectionStep.tsx @@ -3,7 +3,7 @@ import { useCallback } from "react"; import { parseAsStringEnum, useQueryState } from "nuqs"; import { TypographyH3 } from "@/components/Typography"; -import { useStep } from "@/app/(app)/[account]/clean/useStep"; +import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep"; import { ButtonListSurvey } from "@/components/ButtonListSurvey"; import { CleanAction } from "@prisma/client"; diff --git a/apps/web/app/(app)/[account]/clean/CleanHistory.tsx b/apps/web/app/(app)/[emailAccountId]/clean/CleanHistory.tsx similarity index 100% rename from apps/web/app/(app)/[account]/clean/CleanHistory.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/CleanHistory.tsx diff --git a/apps/web/app/(app)/[account]/clean/CleanInstructionsStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/CleanInstructionsStep.tsx similarity index 95% rename from apps/web/app/(app)/[account]/clean/CleanInstructionsStep.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/CleanInstructionsStep.tsx index a26ca0264..9a6b15512 100644 --- a/apps/web/app/(app)/[account]/clean/CleanInstructionsStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/CleanInstructionsStep.tsx @@ -8,9 +8,9 @@ import { z } from "zod"; import { Button } from "@/components/ui/button"; import { TypographyH3 } from "@/components/Typography"; import { Input } from "@/components/Input"; -import { useStep } from "@/app/(app)/[account]/clean/useStep"; +import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep"; import { Toggle } from "@/components/Toggle"; -import { useSkipSettings } from "@/app/(app)/[account]/clean/useSkipSettings"; +import { useSkipSettings } from "@/app/(app)/[emailAccountId]/clean/useSkipSettings"; const schema = z.object({ instructions: z.string().optional() }); diff --git a/apps/web/app/(app)/[account]/clean/CleanRun.tsx b/apps/web/app/(app)/[emailAccountId]/clean/CleanRun.tsx similarity index 83% rename from apps/web/app/(app)/[account]/clean/CleanRun.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/CleanRun.tsx index 422920eb2..fd3aedfea 100644 --- a/apps/web/app/(app)/[account]/clean/CleanRun.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/CleanRun.tsx @@ -1,5 +1,5 @@ -import { EmailFirehose } from "@/app/(app)/[account]/clean/EmailFirehose"; -import { PreviewBatch } from "@/app/(app)/[account]/clean/PreviewBatch"; +import { EmailFirehose } from "@/app/(app)/[emailAccountId]/clean/EmailFirehose"; +import { PreviewBatch } from "@/app/(app)/[emailAccountId]/clean/PreviewBatch"; import { Card } from "@/components/ui/card"; import type { getThreadsByJobId } from "@/utils/redis/clean"; import type { CleanupJob } from "@prisma/client"; diff --git a/apps/web/app/(app)/[account]/clean/CleanStats.tsx b/apps/web/app/(app)/[emailAccountId]/clean/CleanStats.tsx similarity index 100% rename from apps/web/app/(app)/[account]/clean/CleanStats.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/CleanStats.tsx diff --git a/apps/web/app/(app)/[account]/clean/ConfirmationStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx similarity index 96% rename from apps/web/app/(app)/[account]/clean/ConfirmationStep.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx index 9dc323813..90d099d38 100644 --- a/apps/web/app/(app)/[account]/clean/ConfirmationStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx @@ -9,9 +9,9 @@ import { Badge } from "@/components/Badge"; import { cleanInboxAction } from "@/utils/actions/clean"; import { toastError } from "@/components/Toast"; import { CleanAction } from "@prisma/client"; -import { PREVIEW_RUN_COUNT } from "@/app/(app)/[account]/clean/consts"; +import { PREVIEW_RUN_COUNT } from "@/app/(app)/[emailAccountId]/clean/consts"; import { HistoryIcon, SettingsIcon } from "lucide-react"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ConfirmationStep({ showFooter, diff --git a/apps/web/app/(app)/[account]/clean/EmailFirehose.tsx b/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehose.tsx similarity index 100% rename from apps/web/app/(app)/[account]/clean/EmailFirehose.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/EmailFirehose.tsx diff --git a/apps/web/app/(app)/[account]/clean/EmailFirehoseItem.tsx b/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehoseItem.tsx similarity index 100% rename from apps/web/app/(app)/[account]/clean/EmailFirehoseItem.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/EmailFirehoseItem.tsx diff --git a/apps/web/app/(app)/[account]/clean/IntroStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/IntroStep.tsx similarity index 95% rename from apps/web/app/(app)/[account]/clean/IntroStep.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/IntroStep.tsx index 85fc75561..b8fa98f69 100644 --- a/apps/web/app/(app)/[account]/clean/IntroStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/IntroStep.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import { SectionDescription } from "@/components/Typography"; import { TypographyH3 } from "@/components/Typography"; import { Button } from "@/components/ui/button"; -import { useStep } from "@/app/(app)/[account]/clean/useStep"; +import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep"; import { CleanAction } from "@prisma/client"; export function IntroStep({ diff --git a/apps/web/app/(app)/[account]/clean/PreviewBatch.tsx b/apps/web/app/(app)/[emailAccountId]/clean/PreviewBatch.tsx similarity index 94% rename from apps/web/app/(app)/[account]/clean/PreviewBatch.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/PreviewBatch.tsx index adfbe4ae4..cdbbd108b 100644 --- a/apps/web/app/(app)/[account]/clean/PreviewBatch.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/PreviewBatch.tsx @@ -13,8 +13,8 @@ import { } from "@/components/ui/card"; import { cleanInboxAction } from "@/utils/actions/clean"; import { CleanAction, type CleanupJob } from "@prisma/client"; -import { PREVIEW_RUN_COUNT } from "@/app/(app)/[account]/clean/consts"; -import { useAccount } from "@/providers/AccountProvider"; +import { PREVIEW_RUN_COUNT } from "@/app/(app)/[emailAccountId]/clean/consts"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function PreviewBatch({ job }: { job: CleanupJob }) { const { email } = useAccount(); diff --git a/apps/web/app/(app)/[account]/clean/TimeRangeStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/TimeRangeStep.tsx similarity index 85% rename from apps/web/app/(app)/[account]/clean/TimeRangeStep.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/TimeRangeStep.tsx index c5ad5a69e..e110a2c47 100644 --- a/apps/web/app/(app)/[account]/clean/TimeRangeStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/TimeRangeStep.tsx @@ -3,8 +3,8 @@ import { useCallback } from "react"; import { parseAsInteger, useQueryState } from "nuqs"; import { TypographyH3 } from "@/components/Typography"; -import { timeRangeOptions } from "@/app/(app)/[account]/clean/types"; -import { useStep } from "@/app/(app)/[account]/clean/useStep"; +import { timeRangeOptions } from "@/app/(app)/[emailAccountId]/clean/types"; +import { useStep } from "@/app/(app)/[emailAccountId]/clean/useStep"; import { ButtonListSurvey } from "@/components/ButtonListSurvey"; export function TimeRangeStep() { diff --git a/apps/web/app/(app)/[account]/clean/consts.ts b/apps/web/app/(app)/[emailAccountId]/clean/consts.ts similarity index 100% rename from apps/web/app/(app)/[account]/clean/consts.ts rename to apps/web/app/(app)/[emailAccountId]/clean/consts.ts diff --git a/apps/web/app/(app)/[account]/clean/helpers.ts b/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts similarity index 72% rename from apps/web/app/(app)/[account]/clean/helpers.ts rename to apps/web/app/(app)/[emailAccountId]/clean/helpers.ts index 933eda5e9..94ae3b466 100644 --- a/apps/web/app/(app)/[account]/clean/helpers.ts +++ b/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts @@ -12,9 +12,9 @@ export async function getJobById({ }); } -export async function getLastJob({ email }: { email: string }) { +export async function getLastJob({ accountId }: { accountId: string }) { return await prisma.cleanupJob.findFirst({ - where: { email }, + where: { emailAccount: { accountId } }, orderBy: { createdAt: "desc" }, }); } diff --git a/apps/web/app/(app)/[account]/clean/history/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/history/page.tsx similarity index 91% rename from apps/web/app/(app)/[account]/clean/history/page.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/history/page.tsx index 1ac58b97f..ef89f863c 100644 --- a/apps/web/app/(app)/[account]/clean/history/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/history/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { PlusIcon } from "lucide-react"; -import { CleanHistory } from "@/app/(app)/[account]/clean/CleanHistory"; +import { CleanHistory } from "@/app/(app)/[emailAccountId]/clean/CleanHistory"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { PageHeading } from "@/components/Typography"; import { Button } from "@/components/ui/button"; diff --git a/apps/web/app/(app)/[account]/clean/loading.tsx b/apps/web/app/(app)/[emailAccountId]/clean/loading.tsx similarity index 100% rename from apps/web/app/(app)/[account]/clean/loading.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/loading.tsx diff --git a/apps/web/app/(app)/[account]/clean/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx similarity index 74% rename from apps/web/app/(app)/[account]/clean/onboarding/page.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx index 54797080f..ebb214c42 100644 --- a/apps/web/app/(app)/[account]/clean/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx @@ -1,16 +1,16 @@ import { Card } from "@/components/ui/card"; -import { IntroStep } from "@/app/(app)/[account]/clean/IntroStep"; -import { ActionSelectionStep } from "@/app/(app)/[account]/clean/ActionSelectionStep"; -import { CleanInstructionsStep } from "@/app/(app)/[account]/clean/CleanInstructionsStep"; -import { TimeRangeStep } from "@/app/(app)/[account]/clean/TimeRangeStep"; -import { ConfirmationStep } from "@/app/(app)/[account]/clean/ConfirmationStep"; +import { IntroStep } from "@/app/(app)/[emailAccountId]/clean/IntroStep"; +import { ActionSelectionStep } from "@/app/(app)/[emailAccountId]/clean/ActionSelectionStep"; +import { CleanInstructionsStep } from "@/app/(app)/[emailAccountId]/clean/CleanInstructionsStep"; +import { TimeRangeStep } from "@/app/(app)/[emailAccountId]/clean/TimeRangeStep"; +import { ConfirmationStep } from "@/app/(app)/[emailAccountId]/clean/ConfirmationStep"; import { getUnhandledCount } from "@/utils/assess"; -import { CleanStep } from "@/app/(app)/[account]/clean/types"; +import { CleanStep } from "@/app/(app)/[emailAccountId]/clean/types"; import { CleanAction } from "@prisma/client"; -import { getGmailClientForAccountId } from "@/utils/account"; +import { getGmailClientForEmailId } from "@/utils/account"; export default async function CleanPage(props: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; searchParams: Promise<{ step: string; action?: CleanAction; @@ -24,9 +24,9 @@ export default async function CleanPage(props: { }>; }) { const params = await props.params; - const accountId = params.account; + const emailAccountId = params.emailAccountId; - const gmail = await getGmailClientForAccountId({ accountId }); + const gmail = await getGmailClientForEmailId({ emailAccountId }); const { unhandledCount } = await getUnhandledCount(gmail); const searchParams = await props.searchParams; diff --git a/apps/web/app/(app)/[account]/clean/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/page.tsx similarity index 63% rename from apps/web/app/(app)/[account]/clean/page.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/page.tsx index c13b38720..64957c849 100644 --- a/apps/web/app/(app)/[account]/clean/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/page.tsx @@ -1,15 +1,16 @@ import { redirect } from "next/navigation"; -import { getLastJob } from "@/app/(app)/[account]/clean/helpers"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { ConfirmationStep } from "@/app/(app)/[account]/clean/ConfirmationStep"; +import { getLastJob } from "@/app/(app)/[emailAccountId]/clean/helpers"; +import { ConfirmationStep } from "@/app/(app)/[emailAccountId]/clean/ConfirmationStep"; import { Card } from "@/components/ui/card"; -export default async function CleanPage() { - const session = await auth(); - const email = session?.user.email; - if (!email) return
Not authenticated
; +export default async function CleanPage({ + params, +}: { + params: Promise<{ emailAccountId: string }>; +}) { + const { account } = await params; - const lastJob = await getLastJob({ email }); + const lastJob = await getLastJob({ accountId: account }); if (!lastJob) redirect("/clean/onboarding"); return ( diff --git a/apps/web/app/(app)/[account]/clean/run/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx similarity index 81% rename from apps/web/app/(app)/[account]/clean/run/page.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx index c53715d82..e9e60269f 100644 --- a/apps/web/app/(app)/[account]/clean/run/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx @@ -1,15 +1,18 @@ import { getThreadsByJobId } from "@/utils/redis/clean"; import prisma from "@/utils/prisma"; import { CardTitle } from "@/components/ui/card"; -import { getJobById, getLastJob } from "@/app/(app)/[account]/clean/helpers"; -import { CleanRun } from "@/app/(app)/[account]/clean/CleanRun"; +import { + getJobById, + getLastJob, +} from "@/app/(app)/[emailAccountId]/clean/helpers"; +import { CleanRun } from "@/app/(app)/[emailAccountId]/clean/CleanRun"; export default async function CleanRunPage(props: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; searchParams: Promise<{ jobId: string; isPreviewBatch: string }>; }) { const params = await props.params; - const accountId = params.account; + const emailAccountId = params.emailAccountId; const searchParams = await props.searchParams; @@ -28,7 +31,7 @@ export default async function CleanRunPage(props: { const job = jobId ? await getJobById({ email, jobId }) - : await getLastJob({ email }); + : await getLastJob({ accountId }); if (!job) return Job not found; diff --git a/apps/web/app/(app)/[account]/clean/types.ts b/apps/web/app/(app)/[emailAccountId]/clean/types.ts similarity index 100% rename from apps/web/app/(app)/[account]/clean/types.ts rename to apps/web/app/(app)/[emailAccountId]/clean/types.ts diff --git a/apps/web/app/(app)/[account]/clean/useEmailStream.ts b/apps/web/app/(app)/[emailAccountId]/clean/useEmailStream.ts similarity index 100% rename from apps/web/app/(app)/[account]/clean/useEmailStream.ts rename to apps/web/app/(app)/[emailAccountId]/clean/useEmailStream.ts diff --git a/apps/web/app/(app)/[account]/clean/useSkipSettings.ts b/apps/web/app/(app)/[emailAccountId]/clean/useSkipSettings.ts similarity index 100% rename from apps/web/app/(app)/[account]/clean/useSkipSettings.ts rename to apps/web/app/(app)/[emailAccountId]/clean/useSkipSettings.ts diff --git a/apps/web/app/(app)/[account]/clean/useStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/useStep.tsx similarity index 85% rename from apps/web/app/(app)/[account]/clean/useStep.tsx rename to apps/web/app/(app)/[emailAccountId]/clean/useStep.tsx index cc8efaa66..75bc07956 100644 --- a/apps/web/app/(app)/[account]/clean/useStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/useStep.tsx @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { parseAsInteger, useQueryState } from "nuqs"; -import { CleanStep } from "@/app/(app)/[account]/clean/types"; +import { CleanStep } from "@/app/(app)/[emailAccountId]/clean/types"; export function useStep() { const [step, setStep] = useQueryState( diff --git a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx similarity index 97% rename from apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailList.tsx rename to apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx index 9c8c55ba0..b931f654d 100644 --- a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx @@ -14,7 +14,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { DateCell } from "@/app/(app)/[account]/automation/ExecutedRulesTable"; +import { DateCell } from "@/app/(app)/[emailAccountId]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { AlertBasic } from "@/components/Alert"; import { Button } from "@/components/ui/button"; @@ -27,7 +27,7 @@ import { ViewEmailButton } from "@/components/ViewEmailButton"; import { EmailMessageCellWithData } from "@/components/EmailMessageCell"; import { EnableFeatureCard } from "@/components/EnableFeatureCard"; import { toastError, toastSuccess } from "@/components/Toast"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ColdEmailList() { const searchParams = useSearchParams(); diff --git a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailPromptForm.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm.tsx similarity index 97% rename from apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailPromptForm.tsx rename to apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm.tsx index 4af62648a..12e656778 100644 --- a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailPromptForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm.tsx @@ -10,7 +10,7 @@ import { import { DEFAULT_COLD_EMAIL_PROMPT } from "@/utils/cold-email/prompt"; import { toastError, toastSuccess } from "@/components/Toast"; import { updateColdEmailPromptAction } from "@/utils/actions/cold-email"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ColdEmailPromptForm(props: { coldEmailPrompt?: string | null; onSuccess: () => void; diff --git a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailRejected.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx similarity index 94% rename from apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailRejected.tsx rename to apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx index ae1820632..943bc2cb2 100644 --- a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailRejected.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx @@ -11,14 +11,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { DateCell } from "@/app/(app)/[account]/automation/ExecutedRulesTable"; +import { DateCell } from "@/app/(app)/[emailAccountId]/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { AlertBasic } from "@/components/Alert"; import { useSearchParams } from "next/navigation"; import { ColdEmailStatus } from "@prisma/client"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { EmailMessageCellWithData } from "@/components/EmailMessageCell"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ColdEmailRejected() { const searchParams = useSearchParams(); diff --git a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailSettings.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx similarity index 95% rename from apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailSettings.tsx rename to apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx index 61b785801..5e8a65b6a 100644 --- a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailSettings.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx @@ -12,10 +12,10 @@ import { updateColdEmailSettingsBody, } from "@/utils/actions/cold-email.validation"; import { updateColdEmailSettingsAction } from "@/utils/actions/cold-email"; -import { ColdEmailPromptForm } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailPromptForm"; +import { ColdEmailPromptForm } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm"; import { RadioGroup } from "@/components/RadioGroup"; import { useUser } from "@/hooks/useUser"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ColdEmailSettings() { const { data, isLoading, error, mutate } = useUser(); diff --git a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailTest.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailTest.tsx similarity index 82% rename from apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailTest.tsx rename to apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailTest.tsx index 9571abdf0..9634dba5d 100644 --- a/apps/web/app/(app)/[account]/cold-email-blocker/ColdEmailTest.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailTest.tsx @@ -4,7 +4,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { TestRulesContent } from "@/app/(app)/[account]/cold-email-blocker/TestRules"; +import { TestRulesContent } from "@/app/(app)/[emailAccountId]/cold-email-blocker/TestRules"; export function ColdEmailTest() { return ( diff --git a/apps/web/app/(app)/[account]/cold-email-blocker/TestRules.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/TestRules.tsx similarity index 99% rename from apps/web/app/(app)/[account]/cold-email-blocker/TestRules.tsx rename to apps/web/app/(app)/[emailAccountId]/cold-email-blocker/TestRules.tsx index f9385ed32..889f4ba0f 100644 --- a/apps/web/app/(app)/[account]/cold-email-blocker/TestRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/TestRules.tsx @@ -20,7 +20,7 @@ import { TableCell, TableRow } from "@/components/ui/table"; import { CardContent } from "@/components/ui/card"; import { testColdEmailAction } from "@/utils/actions/cold-email"; import type { ColdEmailBlockerBody } from "@/utils/actions/cold-email.validation"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; type ColdEmailBlockerResponse = { isColdEmail: boolean; diff --git a/apps/web/app/(app)/[account]/cold-email-blocker/page.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/page.tsx similarity index 77% rename from apps/web/app/(app)/[account]/cold-email-blocker/page.tsx rename to apps/web/app/(app)/[emailAccountId]/cold-email-blocker/page.tsx index 6ef4accfb..2a3ff06e9 100644 --- a/apps/web/app/(app)/[account]/cold-email-blocker/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/page.tsx @@ -1,12 +1,12 @@ import { Suspense } from "react"; -import { ColdEmailList } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailList"; -import { ColdEmailSettings } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailSettings"; +import { ColdEmailList } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList"; +import { ColdEmailSettings } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings"; import { Card } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; -import { ColdEmailRejected } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailRejected"; -import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; -import { ColdEmailTest } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailTest"; +import { ColdEmailRejected } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; +import { ColdEmailTest } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailTest"; import { TabsToolbar } from "@/components/TabsToolbar"; import { GmailProvider } from "@/providers/GmailProvider"; diff --git a/apps/web/app/(app)/[account]/compose/ComposeEmailForm.tsx b/apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailForm.tsx similarity index 99% rename from apps/web/app/(app)/[account]/compose/ComposeEmailForm.tsx rename to apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailForm.tsx index 307a0726d..0dbb71307 100644 --- a/apps/web/app/(app)/[account]/compose/ComposeEmailForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailForm.tsx @@ -26,7 +26,7 @@ import type { ContactsResponse } from "@/app/api/google/contacts/route"; import type { SendEmailBody } from "@/utils/gmail/mail"; import { CommandShortcut } from "@/components/ui/command"; import { useModifierKey } from "@/hooks/useModifierKey"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export type ReplyingToEmail = { threadId: string; diff --git a/apps/web/app/(app)/[account]/compose/ComposeEmailFormLazy.tsx b/apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailFormLazy.tsx similarity index 100% rename from apps/web/app/(app)/[account]/compose/ComposeEmailFormLazy.tsx rename to apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailFormLazy.tsx diff --git a/apps/web/app/(app)/[account]/compose/page.tsx b/apps/web/app/(app)/[emailAccountId]/compose/page.tsx similarity index 73% rename from apps/web/app/(app)/[account]/compose/page.tsx rename to apps/web/app/(app)/[emailAccountId]/compose/page.tsx index 6ac3c9720..b9f0377cf 100644 --- a/apps/web/app/(app)/[account]/compose/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/compose/page.tsx @@ -1,4 +1,4 @@ -import { ComposeEmailFormLazy } from "@/app/(app)/[account]/compose/ComposeEmailFormLazy"; +import { ComposeEmailFormLazy } from "@/app/(app)/[emailAccountId]/compose/ComposeEmailFormLazy"; import { TopSection } from "@/components/TopSection"; export default function ComposePage() { diff --git a/apps/web/app/(app)/[account]/debug/drafts/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx similarity index 98% rename from apps/web/app/(app)/[account]/debug/drafts/page.tsx rename to apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx index 2d2093746..bc1632504 100644 --- a/apps/web/app/(app)/[account]/debug/drafts/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx @@ -19,7 +19,7 @@ import { Badge } from "@/components/ui/badge"; import { useMessagesBatch } from "@/hooks/useMessagesBatch"; import { LoadingMiniSpinner } from "@/components/Loading"; import { isDefined } from "@/utils/types"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export default function DebugDraftsPage() { const { data, isLoading, error } = useSWR( diff --git a/apps/web/app/(app)/[account]/debug/learned/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/learned/page.tsx similarity index 94% rename from apps/web/app/(app)/[account]/debug/learned/page.tsx rename to apps/web/app/(app)/[emailAccountId]/debug/learned/page.tsx index e8c57f6f0..b0c4fcf10 100644 --- a/apps/web/app/(app)/[account]/debug/learned/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/learned/page.tsx @@ -3,7 +3,7 @@ import useSWR from "swr"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PageHeading, TypographyP } from "@/components/Typography"; -import { ViewGroup } from "@/app/(app)/[account]/automation/group/ViewGroup"; +import { ViewGroup } from "@/app/(app)/[emailAccountId]/automation/group/ViewGroup"; import type { GroupsResponse } from "@/app/api/user/group/route"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/[account]/debug/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/page.tsx similarity index 100% rename from apps/web/app/(app)/[account]/debug/page.tsx rename to apps/web/app/(app)/[emailAccountId]/debug/page.tsx diff --git a/apps/web/app/(app)/[account]/early-access/EarlyAccessFeatures.tsx b/apps/web/app/(app)/[emailAccountId]/early-access/EarlyAccessFeatures.tsx similarity index 100% rename from apps/web/app/(app)/[account]/early-access/EarlyAccessFeatures.tsx rename to apps/web/app/(app)/[emailAccountId]/early-access/EarlyAccessFeatures.tsx diff --git a/apps/web/app/(app)/[account]/early-access/page.tsx b/apps/web/app/(app)/[emailAccountId]/early-access/page.tsx similarity index 95% rename from apps/web/app/(app)/[account]/early-access/page.tsx rename to apps/web/app/(app)/[emailAccountId]/early-access/page.tsx index e01611574..26214e87c 100644 --- a/apps/web/app/(app)/[account]/early-access/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/early-access/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { EarlyAccessFeatures } from "@/app/(app)/[account]/early-access/EarlyAccessFeatures"; +import { EarlyAccessFeatures } from "@/app/(app)/[emailAccountId]/early-access/EarlyAccessFeatures"; import { Button } from "@/components/ui/button"; import { Card, diff --git a/apps/web/app/(app)/[account]/mail/BetaBanner.tsx b/apps/web/app/(app)/[emailAccountId]/mail/BetaBanner.tsx similarity index 100% rename from apps/web/app/(app)/[account]/mail/BetaBanner.tsx rename to apps/web/app/(app)/[emailAccountId]/mail/BetaBanner.tsx diff --git a/apps/web/app/(app)/[account]/mail/page.tsx b/apps/web/app/(app)/[emailAccountId]/mail/page.tsx similarity index 95% rename from apps/web/app/(app)/[account]/mail/page.tsx rename to apps/web/app/(app)/[emailAccountId]/mail/page.tsx index 436d0237a..62aa6212a 100644 --- a/apps/web/app/(app)/[account]/mail/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/mail/page.tsx @@ -8,9 +8,9 @@ import { LoadingContent } from "@/components/LoadingContent"; import type { ThreadsQuery } from "@/app/api/google/threads/validation"; import type { ThreadsResponse } from "@/app/api/google/threads/controller"; import { refetchEmailListAtom } from "@/store/email"; -import { BetaBanner } from "@/app/(app)/[account]/mail/BetaBanner"; +import { BetaBanner } from "@/app/(app)/[emailAccountId]/mail/BetaBanner"; import { ClientOnly } from "@/components/ClientOnly"; -import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; export default function Mail(props: { searchParams: Promise<{ type?: string; labelId?: string }>; diff --git a/apps/web/app/(app)/[account]/no-reply/page.tsx b/apps/web/app/(app)/[emailAccountId]/no-reply/page.tsx similarity index 100% rename from apps/web/app/(app)/[account]/no-reply/page.tsx rename to apps/web/app/(app)/[emailAccountId]/no-reply/page.tsx diff --git a/apps/web/app/(app)/[account]/permissions/consent/page.tsx b/apps/web/app/(app)/[emailAccountId]/permissions/consent/page.tsx similarity index 100% rename from apps/web/app/(app)/[account]/permissions/consent/page.tsx rename to apps/web/app/(app)/[emailAccountId]/permissions/consent/page.tsx diff --git a/apps/web/app/(app)/[account]/permissions/error/page.tsx b/apps/web/app/(app)/[emailAccountId]/permissions/error/page.tsx similarity index 100% rename from apps/web/app/(app)/[account]/permissions/error/page.tsx rename to apps/web/app/(app)/[emailAccountId]/permissions/error/page.tsx diff --git a/apps/web/app/(app)/[account]/premium/PremiumModal.tsx b/apps/web/app/(app)/[emailAccountId]/premium/PremiumModal.tsx similarity index 89% rename from apps/web/app/(app)/[account]/premium/PremiumModal.tsx rename to apps/web/app/(app)/[emailAccountId]/premium/PremiumModal.tsx index c99c9d2d2..7b651b54e 100644 --- a/apps/web/app/(app)/[account]/premium/PremiumModal.tsx +++ b/apps/web/app/(app)/[emailAccountId]/premium/PremiumModal.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; export function usePremiumModal() { const [isOpen, setIsOpen] = useState(false); diff --git a/apps/web/app/(app)/[account]/premium/Pricing.tsx b/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx similarity index 99% rename from apps/web/app/(app)/[account]/premium/Pricing.tsx rename to apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx index afce1331a..54c6f2fe7 100644 --- a/apps/web/app/(app)/[account]/premium/Pricing.tsx +++ b/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx @@ -20,7 +20,7 @@ import { enterpriseTier, frequencies, pricingAdditonalEmail, -} from "@/app/(app)/[account]/premium/config"; +} from "@/app/(app)/[emailAccountId]/premium/config"; import { AlertWithButton } from "@/components/Alert"; import { switchPremiumPlanAction } from "@/utils/actions/premium"; import { TooltipExplanation } from "@/components/TooltipExplanation"; diff --git a/apps/web/app/(app)/[account]/premium/config.ts b/apps/web/app/(app)/[emailAccountId]/premium/config.ts similarity index 100% rename from apps/web/app/(app)/[account]/premium/config.ts rename to apps/web/app/(app)/[emailAccountId]/premium/config.ts diff --git a/apps/web/app/(app)/[account]/premium/page.tsx b/apps/web/app/(app)/[emailAccountId]/premium/page.tsx similarity index 72% rename from apps/web/app/(app)/[account]/premium/page.tsx rename to apps/web/app/(app)/[emailAccountId]/premium/page.tsx index 93116db9a..31a466708 100644 --- a/apps/web/app/(app)/[account]/premium/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/premium/page.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; export default function Premium() { return ( diff --git a/apps/web/app/(app)/[account]/reply-zero/AwaitingReply.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/AwaitingReply.tsx similarity index 80% rename from apps/web/app/(app)/[account]/reply-zero/AwaitingReply.tsx rename to apps/web/app/(app)/[emailAccountId]/reply-zero/AwaitingReply.tsx index e321c97de..8857f3975 100644 --- a/apps/web/app/(app)/[account]/reply-zero/AwaitingReply.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/AwaitingReply.tsx @@ -4,18 +4,20 @@ import { getPaginatedThreadTrackers } from "./fetch-trackers"; import type { TimeRange } from "./date-filter"; export async function AwaitingReply({ - email, + emailAccountId, + userEmail, page, timeRange, isAnalyzing, }: { - email: string; + emailAccountId: string; + userEmail: string; page: number; timeRange: TimeRange; isAnalyzing: boolean; }) { const { trackers, totalPages } = await getPaginatedThreadTrackers({ - email, + emailAccountId, type: ThreadTrackerType.AWAITING, page, timeRange, @@ -24,7 +26,8 @@ export async function AwaitingReply({ return ( void; @@ -306,7 +308,7 @@ function Row({ } subject={message.headers.subject} snippet={message.snippet} - userEmail={email} + userEmail={userEmail} threadId={message.threadId} messageId={message.id} hideViewEmailButton diff --git a/apps/web/app/(app)/[account]/reply-zero/Resolved.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/Resolved.tsx similarity index 86% rename from apps/web/app/(app)/[account]/reply-zero/Resolved.tsx rename to apps/web/app/(app)/[emailAccountId]/reply-zero/Resolved.tsx index b9fb974d2..a5787ab1d 100644 --- a/apps/web/app/(app)/[account]/reply-zero/Resolved.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/Resolved.tsx @@ -6,11 +6,13 @@ import { Prisma } from "@prisma/client"; const PAGE_SIZE = 20; export async function Resolved({ - email, + emailAccountId, + userEmail, page, timeRange, }: { - email: string; + emailAccountId: string; + userEmail: string; page: number; timeRange: TimeRange; }) { @@ -22,7 +24,7 @@ export async function Resolved({ prisma.$queryRaw>` SELECT MAX(id) as id FROM "ThreadTracker" - WHERE "emailAccountId" = ${email} + WHERE "emailAccountId" = ${emailAccountId} ${dateFilter ? Prisma.sql`AND "sentAt" <= (${dateFilter}->>'lte')::timestamp` : Prisma.empty} GROUP BY "threadId" HAVING bool_and(resolved) = true @@ -33,7 +35,7 @@ export async function Resolved({ prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(DISTINCT "threadId") as count FROM "ThreadTracker" - WHERE "emailAccountId" = ${email} + WHERE "emailAccountId" = ${emailAccountId} ${dateFilter ? Prisma.sql`AND "sentAt" <= (${dateFilter}->>'lte')::timestamp` : Prisma.empty} GROUP BY "threadId" HAVING bool_and(resolved) = true @@ -52,7 +54,8 @@ export async function Resolved({ return ( ` SELECT COUNT(DISTINCT "threadId") as count FROM "ThreadTracker" - WHERE "emailAccountId" = ${email} + WHERE "emailAccountId" = ${emailAccountId} AND "resolved" = false AND "type" = ${type}::text::"ThreadTrackerType" AND "sentAt" <= ${dateFilter.lte} @@ -45,7 +45,7 @@ export async function getPaginatedThreadTrackers({ : prisma.$queryRaw<[{ count: bigint }]>` SELECT COUNT(DISTINCT "threadId") as count FROM "ThreadTracker" - WHERE "emailAccountId" = ${email} + WHERE "emailAccountId" = ${emailAccountId} AND "resolved" = false AND "type" = ${type}::text::"ThreadTrackerType" `, diff --git a/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx similarity index 76% rename from apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx rename to apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx index ab5cea2ea..141399afa 100644 --- a/apps/web/app/(app)/[account]/reply-zero/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx @@ -1,9 +1,9 @@ -import { EnableReplyTracker } from "@/app/(app)/[account]/reply-zero/EnableReplyTracker"; +import { EnableReplyTracker } from "@/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker"; import prisma from "@/utils/prisma"; import { ActionType } from "@prisma/client"; export default async function OnboardingReplyTracker(props: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; }) { const params = await props.params; diff --git a/apps/web/app/(app)/[account]/reply-zero/page.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/page.tsx similarity index 83% rename from apps/web/app/(app)/[account]/reply-zero/page.tsx rename to apps/web/app/(app)/[emailAccountId]/reply-zero/page.tsx index 65173ab92..54ba01441 100644 --- a/apps/web/app/(app)/[account]/reply-zero/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/page.tsx @@ -4,7 +4,6 @@ import { CheckCircleIcon, ClockIcon, MailIcon } from "lucide-react"; import { NeedsReply } from "./NeedsReply"; import { Resolved } from "./Resolved"; import { AwaitingReply } from "./AwaitingReply"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { TimeRangeFilter } from "./TimeRangeFilter"; import type { TimeRange } from "./date-filter"; @@ -18,16 +17,15 @@ import { ActionType } from "@prisma/client"; export const maxDuration = 300; export default async function ReplyTrackerPage(props: { + params: Promise<{ emailAccountId: string }>; searchParams: Promise<{ page?: string; timeRange?: TimeRange; enabled?: boolean; }>; }) { + const { emailAccountId } = await props.params; const searchParams = await props.searchParams; - const session = await auth(); - const email = session?.user.email; - if (!email) redirect("/login"); const cookieStore = await cookies(); const viewedOnboarding = @@ -35,17 +33,24 @@ export default async function ReplyTrackerPage(props: { if (!viewedOnboarding) redirect("/reply-zero/onboarding"); - const trackerRule = await prisma.rule.findFirst({ - where: { - emailAccountId: email, - actions: { some: { type: ActionType.TRACK_THREAD } }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + email: true, + rules: { + where: { + actions: { some: { type: ActionType.TRACK_THREAD } }, + }, + select: { id: true }, + }, }, - select: { id: true }, }); + const trackerRule = emailAccount?.rules[0]; + if (!trackerRule) redirect("/reply-zero/onboarding"); - const isAnalyzing = await isAnalyzingReplyTracker({ email }); + const isAnalyzing = await isAnalyzingReplyTracker({ emailAccountId }); const page = Number(searchParams.page || "1"); const timeRange = searchParams.timeRange || "all"; @@ -97,7 +102,8 @@ export default async function ReplyTrackerPage(props: { diff --git a/apps/web/app/(app)/[account]/settings/AboutSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/AboutSection.tsx similarity index 58% rename from apps/web/app/(app)/[account]/settings/AboutSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/AboutSection.tsx index 495768561..5b5a50a9b 100644 --- a/apps/web/app/(app)/[account]/settings/AboutSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/AboutSection.tsx @@ -1,4 +1,4 @@ -import { AboutSectionForm } from "@/app/(app)/[account]/settings/AboutSectionForm"; +import { AboutSectionForm } from "@/app/(app)/[emailAccountId]/settings/AboutSectionForm"; export const AboutSection = async ({ about }: { about: string | null }) => { return ; diff --git a/apps/web/app/(app)/[account]/settings/AboutSectionForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx similarity index 97% rename from apps/web/app/(app)/[account]/settings/AboutSectionForm.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx index 1b618b798..61a331bf5 100644 --- a/apps/web/app/(app)/[account]/settings/AboutSectionForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx @@ -11,7 +11,7 @@ import { FormSectionRight, SubmitButtonWrapper, } from "@/components/Form"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; import { toastError, toastSuccess } from "@/components/Toast"; export const AboutSectionForm = ({ about }: { about: string | null }) => { diff --git a/apps/web/app/(app)/[account]/settings/ApiKeysCreateForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/ApiKeysCreateForm.tsx similarity index 100% rename from apps/web/app/(app)/[account]/settings/ApiKeysCreateForm.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/ApiKeysCreateForm.tsx diff --git a/apps/web/app/(app)/[account]/settings/ApiKeysSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/ApiKeysSection.tsx similarity index 96% rename from apps/web/app/(app)/[account]/settings/ApiKeysSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/ApiKeysSection.tsx index 6faebaee6..9b22be5c0 100644 --- a/apps/web/app/(app)/[account]/settings/ApiKeysSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/ApiKeysSection.tsx @@ -12,7 +12,7 @@ import { import { ApiKeysCreateButtonModal, ApiKeysDeactivateButton, -} from "@/app/(app)/[account]/settings/ApiKeysCreateForm"; +} from "@/app/(app)/[emailAccountId]/settings/ApiKeysCreateForm"; import { Card } from "@/components/ui/card"; export async function ApiKeysSection() { diff --git a/apps/web/app/(app)/[account]/settings/DeleteSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx similarity index 97% rename from apps/web/app/(app)/[account]/settings/DeleteSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx index 262571e37..e622a00dc 100644 --- a/apps/web/app/(app)/[account]/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx @@ -10,7 +10,7 @@ import { } from "@/utils/actions/user"; import { logOut } from "@/utils/user"; import { useStatLoader } from "@/providers/StatLoaderProvider"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function DeleteSection() { const { onCancelLoadBatch } = useStatLoader(); diff --git a/apps/web/app/(app)/[account]/settings/EmailUpdatesSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx similarity index 100% rename from apps/web/app/(app)/[account]/settings/EmailUpdatesSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx diff --git a/apps/web/app/(app)/[account]/settings/LabelsSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx similarity index 99% rename from apps/web/app/(app)/[account]/settings/LabelsSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx index 0d8459543..ac873abc7 100644 --- a/apps/web/app/(app)/[account]/settings/LabelsSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx @@ -35,7 +35,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; const recommendedLabels = ["Newsletter", "Receipt", "Calendar"]; diff --git a/apps/web/app/(app)/[account]/settings/ModelSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx similarity index 100% rename from apps/web/app/(app)/[account]/settings/ModelSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx diff --git a/apps/web/app/(app)/[account]/settings/MultiAccountSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx similarity index 97% rename from apps/web/app/(app)/[account]/settings/MultiAccountSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx index 3952a41f0..48281fadf 100644 --- a/apps/web/app/(app)/[account]/settings/MultiAccountSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx @@ -24,11 +24,11 @@ import { import type { MultiAccountEmailsResponse } from "@/app/api/user/settings/multi-account/route"; import { AlertBasic, AlertWithButton } from "@/components/Alert"; import { usePremium } from "@/components/PremiumAlert"; -import { pricingAdditonalEmail } from "@/app/(app)/[account]/premium/config"; +import { pricingAdditonalEmail } from "@/app/(app)/[emailAccountId]/premium/config"; import { PremiumTier } from "@prisma/client"; import { env } from "@/env"; import { getUserTier, isAdminForPremium } from "@/utils/premium"; -import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; import { useAction } from "next-safe-action/hooks"; import { toastError, toastSuccess } from "@/components/Toast"; diff --git a/apps/web/app/(app)/[account]/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx similarity index 98% rename from apps/web/app/(app)/[account]/settings/SignatureSectionForm.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx index 73212cbf5..70c7e8271 100644 --- a/apps/web/app/(app)/[account]/settings/SignatureSectionForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx @@ -18,7 +18,7 @@ import { import { Tiptap, type TiptapHandle } from "@/components/editor/Tiptap"; import { toastError, toastInfo, toastSuccess } from "@/components/Toast"; import { ClientOnly } from "@/components/ClientOnly"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export const SignatureSectionForm = ({ signature, diff --git a/apps/web/app/(app)/[account]/settings/WebhookGenerate.tsx b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx similarity index 94% rename from apps/web/app/(app)/[account]/settings/WebhookGenerate.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx index c2fc91a43..915b82b66 100644 --- a/apps/web/app/(app)/[account]/settings/WebhookGenerate.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { regenerateWebhookSecretAction } from "@/utils/actions/webhook"; import { toastError, toastSuccess } from "@/components/Toast"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; import { useAction } from "next-safe-action/hooks"; export function RegenerateSecretButton({ hasSecret }: { hasSecret: boolean }) { diff --git a/apps/web/app/(app)/[account]/settings/WebhookSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/WebhookSection.tsx similarity index 89% rename from apps/web/app/(app)/[account]/settings/WebhookSection.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/WebhookSection.tsx index 830724efa..33c81c234 100644 --- a/apps/web/app/(app)/[account]/settings/WebhookSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/WebhookSection.tsx @@ -1,7 +1,7 @@ import { FormSection, FormSectionLeft } from "@/components/Form"; import { Card } from "@/components/ui/card"; import { CopyInput } from "@/components/CopyInput"; -import { RegenerateSecretButton } from "@/app/(app)/[account]/settings/WebhookGenerate"; +import { RegenerateSecretButton } from "@/app/(app)/[emailAccountId]/settings/WebhookGenerate"; export async function WebhookSection({ webhookSecret, diff --git a/apps/web/app/(app)/[account]/settings/page.tsx b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx similarity index 61% rename from apps/web/app/(app)/[account]/settings/page.tsx rename to apps/web/app/(app)/[emailAccountId]/settings/page.tsx index 3f5ac2a21..dd595b842 100644 --- a/apps/web/app/(app)/[account]/settings/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx @@ -1,20 +1,20 @@ import { FormWrapper } from "@/components/Form"; -import { AboutSection } from "@/app/(app)/[account]/settings/AboutSection"; +import { AboutSection } from "@/app/(app)/[emailAccountId]/settings/AboutSection"; // import { SignatureSectionForm } from "@/app/(app)/settings/SignatureSectionForm"; // import { LabelsSection } from "@/app/(app)/settings/LabelsSection"; -import { DeleteSection } from "@/app/(app)/[account]/settings/DeleteSection"; -import { ModelSection } from "@/app/(app)/[account]/settings/ModelSection"; -import { EmailUpdatesSection } from "@/app/(app)/[account]/settings/EmailUpdatesSection"; -import { MultiAccountSection } from "@/app/(app)/[account]/settings/MultiAccountSection"; -import { ApiKeysSection } from "@/app/(app)/[account]/settings/ApiKeysSection"; -import { WebhookSection } from "@/app/(app)/[account]/settings/WebhookSection"; +import { DeleteSection } from "@/app/(app)/[emailAccountId]/settings/DeleteSection"; +import { ModelSection } from "@/app/(app)/[emailAccountId]/settings/ModelSection"; +import { EmailUpdatesSection } from "@/app/(app)/[emailAccountId]/settings/EmailUpdatesSection"; +import { MultiAccountSection } from "@/app/(app)/[emailAccountId]/settings/MultiAccountSection"; +import { ApiKeysSection } from "@/app/(app)/[emailAccountId]/settings/ApiKeysSection"; +import { WebhookSection } from "@/app/(app)/[emailAccountId]/settings/WebhookSection"; import prisma from "@/utils/prisma"; export default async function SettingsPage(props: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; }) { const params = await props.params; - const accountId = params.account; + const emailAccountId = params.emailAccountId; const user = await prisma.emailAccount.findUnique({ where: { accountId }, diff --git a/apps/web/app/(app)/[account]/setup/page.tsx b/apps/web/app/(app)/[emailAccountId]/setup/page.tsx similarity index 100% rename from apps/web/app/(app)/[account]/setup/page.tsx rename to apps/web/app/(app)/[emailAccountId]/setup/page.tsx diff --git a/apps/web/app/(app)/[account]/simple/SimpleList.tsx b/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx similarity index 95% rename from apps/web/app/(app)/[account]/simple/SimpleList.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx index 304887532..ec14004e1 100644 --- a/apps/web/app/(app)/[account]/simple/SimpleList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx @@ -18,12 +18,12 @@ import { extractNameFromEmail } from "@/utils/email"; import { Tooltip } from "@/components/Tooltip"; import type { ParsedMessage } from "@/utils/types"; import { archiveEmails } from "@/store/archive-queue"; -import { Summary } from "@/app/(app)/[account]/simple/Summary"; +import { Summary } from "@/app/(app)/[emailAccountId]/simple/Summary"; import { getGmailUrl } from "@/utils/url"; import { getNextCategory, simpleEmailCategoriesArray, -} from "@/app/(app)/[account]/simple/categories"; +} from "@/app/(app)/[emailAccountId]/simple/categories"; import { DropdownMenu, DropdownMenuTrigger, @@ -34,8 +34,8 @@ import { markImportantMessageAction, markSpamThreadAction, } from "@/utils/actions/mail"; -import { SimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgress"; -import { useSimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; +import { SimpleProgress } from "@/app/(app)/[emailAccountId]/simple/SimpleProgress"; +import { useSimpleProgress } from "@/app/(app)/[emailAccountId]/simple/SimpleProgressProvider"; import { findCtaLink, findUnsubscribeLink, @@ -43,9 +43,9 @@ import { isMarketingEmail, removeReplyFromTextPlain, } from "@/utils/parse/parseHtml.client"; -import { ViewMoreButton } from "@/app/(app)/[account]/simple/ViewMoreButton"; +import { ViewMoreButton } from "@/app/(app)/[emailAccountId]/simple/ViewMoreButton"; import { HtmlEmail } from "@/components/email-list/EmailContents"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function SimpleList(props: { messages: ParsedMessage[]; diff --git a/apps/web/app/(app)/[account]/simple/SimpleModeOnboarding.tsx b/apps/web/app/(app)/[emailAccountId]/simple/SimpleModeOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/[account]/simple/SimpleModeOnboarding.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/SimpleModeOnboarding.tsx diff --git a/apps/web/app/(app)/[account]/simple/SimpleProgress.tsx b/apps/web/app/(app)/[emailAccountId]/simple/SimpleProgress.tsx similarity index 95% rename from apps/web/app/(app)/[account]/simple/SimpleProgress.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/SimpleProgress.tsx index 3150c2488..e0db348ab 100644 --- a/apps/web/app/(app)/[account]/simple/SimpleProgress.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/SimpleProgress.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useSimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; +import { useSimpleProgress } from "@/app/(app)/[emailAccountId]/simple/SimpleProgressProvider"; export function calculateTimePassed(endTime: Date, startTime: Date) { return Math.floor((endTime.getTime() - startTime.getTime()) / 1000); diff --git a/apps/web/app/(app)/[account]/simple/SimpleProgressProvider.tsx b/apps/web/app/(app)/[emailAccountId]/simple/SimpleProgressProvider.tsx similarity index 100% rename from apps/web/app/(app)/[account]/simple/SimpleProgressProvider.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/SimpleProgressProvider.tsx diff --git a/apps/web/app/(app)/[account]/simple/Summary.tsx b/apps/web/app/(app)/[emailAccountId]/simple/Summary.tsx similarity index 91% rename from apps/web/app/(app)/[account]/simple/Summary.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/Summary.tsx index 7951bbd63..074494fd2 100644 --- a/apps/web/app/(app)/[account]/simple/Summary.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/Summary.tsx @@ -3,7 +3,7 @@ import { useCompletion } from "ai/react"; import { useEffect } from "react"; import { ButtonLoader } from "@/components/Loading"; -import { ViewMoreButton } from "@/app/(app)/[account]/simple/ViewMoreButton"; +import { ViewMoreButton } from "@/app/(app)/[emailAccountId]/simple/ViewMoreButton"; export function Summary({ textHtml, diff --git a/apps/web/app/(app)/[account]/simple/ViewMoreButton.tsx b/apps/web/app/(app)/[emailAccountId]/simple/ViewMoreButton.tsx similarity index 100% rename from apps/web/app/(app)/[account]/simple/ViewMoreButton.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/ViewMoreButton.tsx diff --git a/apps/web/app/(app)/[account]/simple/categories.ts b/apps/web/app/(app)/[emailAccountId]/simple/categories.ts similarity index 100% rename from apps/web/app/(app)/[account]/simple/categories.ts rename to apps/web/app/(app)/[emailAccountId]/simple/categories.ts diff --git a/apps/web/app/(app)/[account]/simple/completed/OpenMultipleGmailButton.tsx b/apps/web/app/(app)/[emailAccountId]/simple/completed/OpenMultipleGmailButton.tsx similarity index 100% rename from apps/web/app/(app)/[account]/simple/completed/OpenMultipleGmailButton.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/completed/OpenMultipleGmailButton.tsx diff --git a/apps/web/app/(app)/[account]/simple/completed/ShareOnTwitterButton.tsx b/apps/web/app/(app)/[emailAccountId]/simple/completed/ShareOnTwitterButton.tsx similarity index 83% rename from apps/web/app/(app)/[account]/simple/completed/ShareOnTwitterButton.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/completed/ShareOnTwitterButton.tsx index 40dd76897..1f4a53bb1 100644 --- a/apps/web/app/(app)/[account]/simple/completed/ShareOnTwitterButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/completed/ShareOnTwitterButton.tsx @@ -3,11 +3,11 @@ import { ExternalLinkIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { useSimpleProgress } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; +import { useSimpleProgress } from "@/app/(app)/[emailAccountId]/simple/SimpleProgressProvider"; import { calculateTimePassed, formatTime, -} from "@/app/(app)/[account]/simple/SimpleProgress"; +} from "@/app/(app)/[emailAccountId]/simple/SimpleProgress"; export function ShareOnTwitterButton() { const { handled, startTime, endTime } = useSimpleProgress(); diff --git a/apps/web/app/(app)/[account]/simple/completed/page.tsx b/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx similarity index 87% rename from apps/web/app/(app)/[account]/simple/completed/page.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx index 290ffa04e..8cafde9aa 100644 --- a/apps/web/app/(app)/[account]/simple/completed/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx @@ -5,15 +5,15 @@ import { EmailList } from "@/components/email-list/EmailList"; import { getThreads } from "@/app/api/google/threads/controller"; import { Button } from "@/components/ui/button"; import { getGmailBasicSearchUrl } from "@/utils/url"; -import { OpenMultipleGmailButton } from "@/app/(app)/[account]/simple/completed/OpenMultipleGmailButton"; -import { SimpleProgressCompleted } from "@/app/(app)/[account]/simple/SimpleProgress"; -import { ShareOnTwitterButton } from "@/app/(app)/[account]/simple/completed/ShareOnTwitterButton"; +import { OpenMultipleGmailButton } from "@/app/(app)/[emailAccountId]/simple/completed/OpenMultipleGmailButton"; +import { SimpleProgressCompleted } from "@/app/(app)/[emailAccountId]/simple/SimpleProgress"; +import { ShareOnTwitterButton } from "@/app/(app)/[emailAccountId]/simple/completed/ShareOnTwitterButton"; import { getTokens } from "@/utils/account"; import { getGmailClient, getGmailAccessToken } from "@/utils/gmail/client"; import prisma from "@/utils/prisma"; export default async function SimpleCompletedPage(props: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; }) { const params = await props.params; diff --git a/apps/web/app/(app)/[account]/simple/layout.tsx b/apps/web/app/(app)/[emailAccountId]/simple/layout.tsx similarity index 62% rename from apps/web/app/(app)/[account]/simple/layout.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/layout.tsx index 664456f52..476fb41c3 100644 --- a/apps/web/app/(app)/[account]/simple/layout.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/layout.tsx @@ -1,4 +1,4 @@ -import { SimpleEmailStateProvider } from "@/app/(app)/[account]/simple/SimpleProgressProvider"; +import { SimpleEmailStateProvider } from "@/app/(app)/[emailAccountId]/simple/SimpleProgressProvider"; export default async function SimpleLayout({ children, diff --git a/apps/web/app/(app)/[account]/simple/loading.tsx b/apps/web/app/(app)/[emailAccountId]/simple/loading.tsx similarity index 100% rename from apps/web/app/(app)/[account]/simple/loading.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/loading.tsx diff --git a/apps/web/app/(app)/[account]/simple/page.tsx b/apps/web/app/(app)/[emailAccountId]/simple/page.tsx similarity index 86% rename from apps/web/app/(app)/[account]/simple/page.tsx rename to apps/web/app/(app)/[emailAccountId]/simple/page.tsx index a8d4cc0c4..9f3b53d2b 100644 --- a/apps/web/app/(app)/[account]/simple/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/page.tsx @@ -1,31 +1,31 @@ import { redirect, RedirectType } from "next/navigation"; -import { SimpleList } from "@/app/(app)/[account]/simple/SimpleList"; +import { SimpleList } from "@/app/(app)/[emailAccountId]/simple/SimpleList"; import { getNextCategory, simpleEmailCategories, simpleEmailCategoriesArray, -} from "@/app/(app)/[account]/simple/categories"; +} from "@/app/(app)/[emailAccountId]/simple/categories"; import { PageHeading } from "@/components/Typography"; import { parseMessage } from "@/utils/mail"; -import { SimpleModeOnboarding } from "@/app/(app)/[account]/simple/SimpleModeOnboarding"; +import { SimpleModeOnboarding } from "@/app/(app)/[emailAccountId]/simple/SimpleModeOnboarding"; import { ClientOnly } from "@/components/ClientOnly"; import { getMessage, getMessages } from "@/utils/gmail/message"; -import { getGmailClientForAccountId } from "@/utils/account"; +import { getGmailClientForEmailId } from "@/utils/account"; export const dynamic = "force-dynamic"; export default async function SimplePage(props: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; searchParams: Promise<{ pageToken?: string; type?: string }>; }) { const params = await props.params; - const accountId = params.account; + const emailAccountId = params.emailAccountId; const searchParams = await props.searchParams; const { pageToken, type = "IMPORTANT" } = searchParams; - const gmail = await getGmailClientForAccountId({ accountId }); + const gmail = await getGmailClientForEmailId({ emailAccountId }); const categoryTitle = simpleEmailCategories.get(type); diff --git a/apps/web/app/(app)/[account]/smart-categories/CategorizeProgress.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress.tsx similarity index 100% rename from apps/web/app/(app)/[account]/smart-categories/CategorizeProgress.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress.tsx diff --git a/apps/web/app/(app)/[account]/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx similarity index 92% rename from apps/web/app/(app)/[account]/smart-categories/CategorizeWithAiButton.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx index 8ba993007..87e1d6076 100644 --- a/apps/web/app/(app)/[account]/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx @@ -6,11 +6,11 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { bulkCategorizeSendersAction } from "@/utils/actions/categorize"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; -import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; import type { ButtonProps } from "@/components/ui/button"; -import { useCategorizeProgress } from "@/app/(app)/[account]/smart-categories/CategorizeProgress"; +import { useCategorizeProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; import { Tooltip } from "@/components/Tooltip"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function CategorizeWithAiButton({ buttonProps, diff --git a/apps/web/app/(app)/[account]/smart-categories/CreateCategoryButton.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton.tsx similarity index 99% rename from apps/web/app/(app)/[account]/smart-categories/CreateCategoryButton.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton.tsx index a947a38c3..407a5c92f 100644 --- a/apps/web/app/(app)/[account]/smart-categories/CreateCategoryButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/dialog"; import type { Category } from "@prisma/client"; import { MessageText } from "@/components/Typography"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; type ExampleCategory = { name: string; diff --git a/apps/web/app/(app)/[account]/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx similarity index 97% rename from apps/web/app/(app)/[account]/smart-categories/Uncategorized.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx index cf679d810..1d4b33b0f 100644 --- a/apps/web/app/(app)/[account]/smart-categories/Uncategorized.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx @@ -18,12 +18,12 @@ import { import { SectionDescription } from "@/components/Typography"; import { ButtonLoader } from "@/components/Loading"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; -import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; import { Toggle } from "@/components/Toggle"; import { setAutoCategorizeAction } from "@/utils/actions/categorize"; import { TooltipExplanation } from "@/components/TooltipExplanation"; import type { CategoryWithRules } from "@/utils/category.server"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function Uncategorized({ categories, diff --git a/apps/web/app/(app)/[account]/smart-categories/page.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx similarity index 88% rename from apps/web/app/(app)/[account]/smart-categories/page.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx index ca4e3a1ae..70a0d887e 100644 --- a/apps/web/app/(app)/[account]/smart-categories/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx @@ -8,9 +8,9 @@ import prisma from "@/utils/prisma"; import { ClientOnly } from "@/components/ClientOnly"; import { GroupedTable } from "@/components/GroupedTable"; import { TopBar } from "@/components/TopBar"; -import { CreateCategoryButton } from "@/app/(app)/[account]/smart-categories/CreateCategoryButton"; +import { CreateCategoryButton } from "@/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton"; import { getUserCategoriesWithRules } from "@/utils/category.server"; -import { CategorizeWithAiButton } from "@/app/(app)/[account]/smart-categories/CategorizeWithAiButton"; +import { CategorizeWithAiButton } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton"; import { Card, CardContent, @@ -19,12 +19,12 @@ import { CardDescription, } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Uncategorized } from "@/app/(app)/[account]/smart-categories/Uncategorized"; -import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; -import { ArchiveProgress } from "@/app/(app)/[account]/bulk-unsubscribe/ArchiveProgress"; +import { Uncategorized } from "@/app/(app)/[emailAccountId]/smart-categories/Uncategorized"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; +import { ArchiveProgress } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ArchiveProgress"; import { PremiumAlertWithData } from "@/components/PremiumAlert"; import { Button } from "@/components/ui/button"; -import { CategorizeSendersProgress } from "@/app/(app)/[account]/smart-categories/CategorizeProgress"; +import { CategorizeSendersProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; import { getCategorizationProgress } from "@/utils/redis/categorization-progress"; export const dynamic = "force-dynamic"; diff --git a/apps/web/app/(app)/[account]/smart-categories/setup/SetUpCategories.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories.tsx similarity index 98% rename from apps/web/app/(app)/[account]/smart-categories/setup/SetUpCategories.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories.tsx index eb0f54f14..bcb6e4d26 100644 --- a/apps/web/app/(app)/[account]/smart-categories/setup/SetUpCategories.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories.tsx @@ -23,7 +23,7 @@ import { cn } from "@/utils"; import { CreateCategoryButton, CreateCategoryDialog, -} from "@/app/(app)/[account]/smart-categories/CreateCategoryButton"; +} from "@/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton"; import type { Category } from "@prisma/client"; type CardCategory = Pick & { diff --git a/apps/web/app/(app)/[account]/smart-categories/setup/SmartCategoriesOnboarding.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SmartCategoriesOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/[account]/smart-categories/setup/SmartCategoriesOnboarding.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SmartCategoriesOnboarding.tsx diff --git a/apps/web/app/(app)/[account]/smart-categories/setup/page.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx similarity index 71% rename from apps/web/app/(app)/[account]/smart-categories/setup/page.tsx rename to apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx index 9f679af4f..b18dd122e 100644 --- a/apps/web/app/(app)/[account]/smart-categories/setup/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx @@ -1,5 +1,5 @@ -import { SetUpCategories } from "@/app/(app)/[account]/smart-categories/setup/SetUpCategories"; -import { SmartCategoriesOnboarding } from "@/app/(app)/[account]/smart-categories/setup/SmartCategoriesOnboarding"; +import { SetUpCategories } from "@/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories"; +import { SmartCategoriesOnboarding } from "@/app/(app)/[emailAccountId]/smart-categories/setup/SmartCategoriesOnboarding"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { ClientOnly } from "@/components/ClientOnly"; import { getUserCategories } from "@/utils/category.server"; diff --git a/apps/web/app/(app)/[account]/stats/ActionBar.tsx b/apps/web/app/(app)/[emailAccountId]/stats/ActionBar.tsx similarity index 96% rename from apps/web/app/(app)/[account]/stats/ActionBar.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/ActionBar.tsx index 383004424..8912c62b9 100644 --- a/apps/web/app/(app)/[account]/stats/ActionBar.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/ActionBar.tsx @@ -2,7 +2,7 @@ import React from "react"; import subDays from "date-fns/subDays"; import { GanttChartIcon, Tally3Icon } from "lucide-react"; import type { DateRange } from "react-day-picker"; -import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; +import { DetailedStatsFilter } from "@/app/(app)/[emailAccountId]/stats/DetailedStatsFilter"; import { DatePickerWithRange } from "@/components/DatePickerWithRange"; export function ActionBar({ diff --git a/apps/web/app/(app)/[account]/stats/CombinedStatsChart.tsx b/apps/web/app/(app)/[emailAccountId]/stats/CombinedStatsChart.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/CombinedStatsChart.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/CombinedStatsChart.tsx diff --git a/apps/web/app/(app)/[account]/stats/DetailedStats.tsx b/apps/web/app/(app)/[emailAccountId]/stats/DetailedStats.tsx similarity index 96% rename from apps/web/app/(app)/[account]/stats/DetailedStats.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/DetailedStats.tsx index 5bf3e45b5..3cb726ba3 100644 --- a/apps/web/app/(app)/[account]/stats/DetailedStats.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/DetailedStats.tsx @@ -12,8 +12,8 @@ import type { StatsByWeekResponse, StatsByWeekParams, } from "@/app/api/user/stats/tinybird/route"; -import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; -import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; +import { DetailedStatsFilter } from "@/app/(app)/[emailAccountId]/stats/DetailedStatsFilter"; +import { getDateRangeParams } from "@/app/(app)/[emailAccountId]/stats/params"; export function DetailedStats(props: { dateRange?: DateRange | undefined; diff --git a/apps/web/app/(app)/[account]/stats/DetailedStatsFilter.tsx b/apps/web/app/(app)/[emailAccountId]/stats/DetailedStatsFilter.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/DetailedStatsFilter.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/DetailedStatsFilter.tsx diff --git a/apps/web/app/(app)/[account]/stats/EmailActionsAnalytics.tsx b/apps/web/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/EmailActionsAnalytics.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics.tsx diff --git a/apps/web/app/(app)/[account]/stats/EmailAnalytics.tsx b/apps/web/app/(app)/[emailAccountId]/stats/EmailAnalytics.tsx similarity index 93% rename from apps/web/app/(app)/[account]/stats/EmailAnalytics.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/EmailAnalytics.tsx index 93e1f24b2..9258223c6 100644 --- a/apps/web/app/(app)/[account]/stats/EmailAnalytics.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/EmailAnalytics.tsx @@ -2,15 +2,15 @@ import useSWR from "swr"; import type { DateRange } from "react-day-picker"; -import { useExpanded } from "@/app/(app)/[account]/stats/useExpanded"; +import { useExpanded } from "@/app/(app)/[emailAccountId]/stats/useExpanded"; import type { RecipientsResponse } from "@/app/api/user/stats/recipients/route"; import type { SendersResponse } from "@/app/api/user/stats/senders/route"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; import { BarList } from "@/components/charts/BarList"; -import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; +import { getDateRangeParams } from "@/app/(app)/[emailAccountId]/stats/params"; import { getGmailSearchUrl } from "@/utils/url"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function EmailAnalytics(props: { dateRange?: DateRange | undefined; refreshInterval: number; diff --git a/apps/web/app/(app)/[account]/stats/EmailsToIncludeFilter.tsx b/apps/web/app/(app)/[emailAccountId]/stats/EmailsToIncludeFilter.tsx similarity index 94% rename from apps/web/app/(app)/[account]/stats/EmailsToIncludeFilter.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/EmailsToIncludeFilter.tsx index bd36788e5..63fbb91bf 100644 --- a/apps/web/app/(app)/[account]/stats/EmailsToIncludeFilter.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/EmailsToIncludeFilter.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { FilterIcon } from "lucide-react"; -import { DetailedStatsFilter } from "@/app/(app)/[account]/stats/DetailedStatsFilter"; +import { DetailedStatsFilter } from "@/app/(app)/[emailAccountId]/stats/DetailedStatsFilter"; export function useEmailsToIncludeFilter() { const [types, setTypes] = useState< diff --git a/apps/web/app/(app)/[account]/stats/LoadProgress.tsx b/apps/web/app/(app)/[emailAccountId]/stats/LoadProgress.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/LoadProgress.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/LoadProgress.tsx diff --git a/apps/web/app/(app)/[account]/stats/LoadStatsButton.tsx b/apps/web/app/(app)/[emailAccountId]/stats/LoadStatsButton.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/LoadStatsButton.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/LoadStatsButton.tsx diff --git a/apps/web/app/(app)/[account]/stats/NewsletterModal.tsx b/apps/web/app/(app)/[emailAccountId]/stats/NewsletterModal.tsx similarity index 95% rename from apps/web/app/(app)/[account]/stats/NewsletterModal.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/NewsletterModal.tsx index 4500c9365..4818d9920 100644 --- a/apps/web/app/(app)/[account]/stats/NewsletterModal.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/NewsletterModal.tsx @@ -10,7 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { getDateRangeParams } from "@/app/(app)/[account]/stats/params"; +import { getDateRangeParams } from "@/app/(app)/[emailAccountId]/stats/params"; import type { SenderEmailsQuery, SenderEmailsResponse, @@ -24,11 +24,11 @@ import { Button } from "@/components/ui/button"; import { getGmailFilterSettingsUrl } from "@/utils/url"; import { Tooltip } from "@/components/Tooltip"; import { AlertBasic } from "@/components/Alert"; -import { MoreDropdown } from "@/app/(app)/[account]/bulk-unsubscribe/common"; +import { MoreDropdown } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/common"; import { useLabels } from "@/hooks/useLabels"; -import type { Row } from "@/app/(app)/[account]/bulk-unsubscribe/types"; +import type { Row } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import { useThreads } from "@/hooks/useThreads"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; import { onAutoArchive } from "@/utils/actions/client"; export function NewsletterModal(props: { diff --git a/apps/web/app/(app)/[account]/stats/Stats.tsx b/apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx similarity index 79% rename from apps/web/app/(app)/[account]/stats/Stats.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx index 09e82ef31..53df717b6 100644 --- a/apps/web/app/(app)/[account]/stats/Stats.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx @@ -3,16 +3,16 @@ import { useState, useMemo, useCallback, useEffect } from "react"; import type { DateRange } from "react-day-picker"; import subDays from "date-fns/subDays"; -import { DetailedStats } from "@/app/(app)/[account]/stats/DetailedStats"; -import { LoadStatsButton } from "@/app/(app)/[account]/stats/LoadStatsButton"; -import { EmailAnalytics } from "@/app/(app)/[account]/stats/EmailAnalytics"; -import { StatsSummary } from "@/app/(app)/[account]/stats/StatsSummary"; -import { StatsOnboarding } from "@/app/(app)/[account]/stats/StatsOnboarding"; -import { ActionBar } from "@/app/(app)/[account]/stats/ActionBar"; -import { LoadProgress } from "@/app/(app)/[account]/stats/LoadProgress"; +import { DetailedStats } from "@/app/(app)/[emailAccountId]/stats/DetailedStats"; +import { LoadStatsButton } from "@/app/(app)/[emailAccountId]/stats/LoadStatsButton"; +import { EmailAnalytics } from "@/app/(app)/[emailAccountId]/stats/EmailAnalytics"; +import { StatsSummary } from "@/app/(app)/[emailAccountId]/stats/StatsSummary"; +import { StatsOnboarding } from "@/app/(app)/[emailAccountId]/stats/StatsOnboarding"; +import { ActionBar } from "@/app/(app)/[emailAccountId]/stats/ActionBar"; +import { LoadProgress } from "@/app/(app)/[emailAccountId]/stats/LoadProgress"; import { useStatLoader } from "@/providers/StatLoaderProvider"; -import { EmailActionsAnalytics } from "@/app/(app)/[account]/stats/EmailActionsAnalytics"; -import { BulkUnsubscribeSummary } from "@/app/(app)/[account]/bulk-unsubscribe/BulkUnsubscribeSummary"; +import { EmailActionsAnalytics } from "@/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics"; +import { BulkUnsubscribeSummary } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSummary"; import { CardBasic } from "@/components/ui/card"; import { Title } from "@tremor/react"; import { TopBar } from "@/components/TopBar"; diff --git a/apps/web/app/(app)/[account]/stats/StatsChart.tsx b/apps/web/app/(app)/[emailAccountId]/stats/StatsChart.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/StatsChart.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/StatsChart.tsx diff --git a/apps/web/app/(app)/[account]/stats/StatsOnboarding.tsx b/apps/web/app/(app)/[emailAccountId]/stats/StatsOnboarding.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/StatsOnboarding.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/StatsOnboarding.tsx diff --git a/apps/web/app/(app)/[account]/stats/StatsSummary.tsx b/apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/StatsSummary.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/StatsSummary.tsx diff --git a/apps/web/app/(app)/[account]/stats/page.tsx b/apps/web/app/(app)/[emailAccountId]/stats/page.tsx similarity index 77% rename from apps/web/app/(app)/[account]/stats/page.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/page.tsx index 14075308a..0d288544a 100644 --- a/apps/web/app/(app)/[account]/stats/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/page.tsx @@ -1,4 +1,4 @@ -import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; import { Stats } from "./Stats"; import { checkAndRedirectForUpgrade } from "@/utils/premium/check-and-redirect-for-upgrade"; diff --git a/apps/web/app/(app)/[account]/stats/params.ts b/apps/web/app/(app)/[emailAccountId]/stats/params.ts similarity index 100% rename from apps/web/app/(app)/[account]/stats/params.ts rename to apps/web/app/(app)/[emailAccountId]/stats/params.ts diff --git a/apps/web/app/(app)/[account]/stats/useExpanded.tsx b/apps/web/app/(app)/[emailAccountId]/stats/useExpanded.tsx similarity index 100% rename from apps/web/app/(app)/[account]/stats/useExpanded.tsx rename to apps/web/app/(app)/[emailAccountId]/stats/useExpanded.tsx diff --git a/apps/web/app/(app)/[account]/usage/page.tsx b/apps/web/app/(app)/[emailAccountId]/usage/page.tsx similarity index 79% rename from apps/web/app/(app)/[account]/usage/page.tsx rename to apps/web/app/(app)/[emailAccountId]/usage/page.tsx index 197769ca3..ec2e56403 100644 --- a/apps/web/app/(app)/[account]/usage/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/usage/page.tsx @@ -1,13 +1,13 @@ import { getUsage } from "@/utils/redis/usage"; import { TopSection } from "@/components/TopSection"; -import { Usage } from "@/app/(app)/[account]/usage/usage"; +import { Usage } from "@/app/(app)/[emailAccountId]/usage/usage"; import prisma from "@/utils/prisma"; export default async function UsagePage(props: { - params: Promise<{ account: string }>; + params: Promise<{ emailAccountId: string }>; }) { const params = await props.params; - const accountId = params.account; + const emailAccountId = params.emailAccountId; const emailAccount = await prisma.emailAccount.findUnique({ where: { accountId }, diff --git a/apps/web/app/(app)/[account]/usage/usage.tsx b/apps/web/app/(app)/[emailAccountId]/usage/usage.tsx similarity index 100% rename from apps/web/app/(app)/[account]/usage/usage.tsx rename to apps/web/app/(app)/[emailAccountId]/usage/usage.tsx diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 59f0051aa..4ad8131c2 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -10,7 +10,7 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { PostHogIdentify } from "@/providers/PostHogProvider"; import { CommandK } from "@/components/CommandK"; import { AppProviders } from "@/providers/AppProviders"; -import { AssessUser } from "@/app/(app)/[account]/assess"; +import { AssessUser } from "@/app/(app)/[emailAccountId]/assess"; import { LastLogin } from "@/app/(app)/last-login"; import { SentryIdentify } from "@/app/(app)/sentry-identify"; import { ErrorMessages } from "@/app/(app)/ErrorMessages"; diff --git a/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx b/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx index 9fe5baba9..2f12a0176 100644 --- a/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx @@ -22,7 +22,7 @@ import type { import { LoadingContent } from "@/components/LoadingContent"; import { ProgressBar } from "@tremor/react"; import { ONE_MONTH_MS } from "@/utils/date"; -import { useUnsubscribe } from "@/app/(app)/[account]/bulk-unsubscribe/hooks"; +import { useUnsubscribe } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks"; import { NewsletterStatus } from "@prisma/client"; import { EmailCell } from "@/components/EmailCell"; diff --git a/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx b/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx index 9d35aa0dd..f90f6ac94 100644 --- a/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingColdEmailBlocker.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { ColdEmailForm } from "@/app/(app)/[account]/cold-email-blocker/ColdEmailSettings"; +import { ColdEmailForm } from "@/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings"; import { useUser } from "@/hooks/useUser"; import { LoadingContent } from "@/components/LoadingContent"; diff --git a/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx b/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx index 88ebe7053..c1ee841b5 100644 --- a/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingEmailAssistant.tsx @@ -29,8 +29,8 @@ import { rulesExamplesBody, type RulesExamplesBody, } from "@/utils/actions/rule.validation"; -import { examplePrompts } from "@/app/(app)/[account]/automation/examples"; -import { useAccount } from "@/providers/AccountProvider"; +import { examplePrompts } from "@/app/(app)/[emailAccountId]/automation/examples"; +import { useAccount } from "@/providers/EmailAccountProvider"; type RulesExamplesResponse = InferSafeActionFnResult< typeof getRuleExamplesAction diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index d1cd04168..7225c1ef5 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -4,7 +4,7 @@ import { OnboardingBulkUnsubscriber } from "@/app/(app)/onboarding/OnboardingBul import { OnboardingColdEmailBlocker } from "@/app/(app)/onboarding/OnboardingColdEmailBlocker"; import { OnboardingAIEmailAssistant } from "@/app/(app)/onboarding/OnboardingEmailAssistant"; import { OnboardingFinish } from "@/app/(app)/onboarding/OnboardingFinish"; -import { PermissionsCheck } from "@/app/(app)/[account]/PermissionsCheck"; +import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; import { LoadStats } from "@/providers/StatLoaderProvider"; export const maxDuration = 120; diff --git a/apps/web/app/(landing)/ai-automation/page.tsx b/apps/web/app/(landing)/ai-automation/page.tsx index 884fb0c9f..d4d5f3e26 100644 --- a/apps/web/app/(landing)/ai-automation/page.tsx +++ b/apps/web/app/(landing)/ai-automation/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { FeaturesAiAssistant } from "@/app/(landing)/home/Features"; diff --git a/apps/web/app/(landing)/block-cold-emails/page.tsx b/apps/web/app/(landing)/block-cold-emails/page.tsx index 04adca4de..842b74016 100644 --- a/apps/web/app/(landing)/block-cold-emails/page.tsx +++ b/apps/web/app/(landing)/block-cold-emails/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesColdEmailBlocker } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx b/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx index a7a08a052..d93aec8a7 100644 --- a/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx +++ b/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesUnsubscribe } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/email-analytics/page.tsx b/apps/web/app/(landing)/email-analytics/page.tsx index d1eda3061..d9872bfd5 100644 --- a/apps/web/app/(landing)/email-analytics/page.tsx +++ b/apps/web/app/(landing)/email-analytics/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { FeaturesStats } from "@/app/(landing)/home/Features"; diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx index 4e444472d..075f3677b 100644 --- a/apps/web/app/(landing)/page.tsx +++ b/apps/web/app/(landing)/page.tsx @@ -4,7 +4,7 @@ import { HeroHome } from "@/app/(landing)/home/Hero"; import { FeaturesHome } from "@/app/(landing)/home/Features"; import { Privacy } from "@/app/(landing)/home/Privacy"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/reply-zero-ai/page.tsx b/apps/web/app/(landing)/reply-zero-ai/page.tsx index fa49622d9..ae33515cf 100644 --- a/apps/web/app/(landing)/reply-zero-ai/page.tsx +++ b/apps/web/app/(landing)/reply-zero-ai/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesReplyZero } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/welcome-upgrade/page.tsx b/apps/web/app/(landing)/welcome-upgrade/page.tsx index bb82f41ca..ed7472252 100644 --- a/apps/web/app/(landing)/welcome-upgrade/page.tsx +++ b/apps/web/app/(landing)/welcome-upgrade/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import { CheckCircleIcon } from "lucide-react"; -import { Pricing } from "@/app/(app)/[account]/premium/Pricing"; +import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; import { Footer } from "@/app/(landing)/home/Footer"; import { Loading } from "@/components/Loading"; import { WelcomeUpgradeNav } from "@/app/(landing)/welcome-upgrade/WelcomeUpgradeNav"; diff --git a/apps/web/app/api/ai/analyze-sender-pattern/route.ts b/apps/web/app/api/ai/analyze-sender-pattern/route.ts index 639857ade..8f74a636d 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/route.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/route.ts @@ -128,7 +128,7 @@ async function process({ email, from }: { email: string; from: string }) { // Detect pattern using AI const patternResult = await aiDetectRecurringPattern({ emails, - user: emailAccount, + emailAccount, rules: emailAccount.rules.map((rule) => ({ name: rule.name, instructions: rule.instructions || "", @@ -297,12 +297,17 @@ async function getEmailAccountWithRules({ email }: { email: string }) { return await prisma.emailAccount.findUnique({ where: { email }, select: { + id: true, userId: true, email: true, about: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, account: { select: { access_token: true, diff --git a/apps/web/app/api/ai/compose-autocomplete/route.ts b/apps/web/app/api/ai/compose-autocomplete/route.ts index 8698d96bf..8dd1d4d4a 100644 --- a/apps/web/app/api/ai/compose-autocomplete/route.ts +++ b/apps/web/app/api/ai/compose-autocomplete/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { composeAutocompleteBody } from "@/app/api/ai/compose-autocomplete/validation"; import { chatCompletionStream } from "@/utils/llms"; import { getAiUser } from "@/utils/user/get"; -export const POST = withAuth(async (request) => { - const email = request.auth.userEmail; +export const POST = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; - const user = await getAiUser({ email }); + const user = await getAiUser({ emailAccountId }); if (!user) return NextResponse.json({ error: "Not authenticated" }); @@ -19,10 +19,10 @@ Give more weight/priority to the later characters than the beginning ones. Limit your response to no more than 200 characters, but make sure to construct complete sentences.`; const response = await chatCompletionStream({ - userAi: user, + userAi: user.user, system, prompt, - userEmail: email, + userEmail: user.email, usageLabel: "Compose auto complete", }); diff --git a/apps/web/app/api/ai/summarise/controller.ts b/apps/web/app/api/ai/summarise/controller.ts index 160854efe..c05a8aaf3 100644 --- a/apps/web/app/api/ai/summarise/controller.ts +++ b/apps/web/app/api/ai/summarise/controller.ts @@ -1,13 +1,17 @@ import { chatCompletionStream } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { expire } from "@/utils/redis"; import { saveSummary } from "@/utils/redis/summary"; -export async function summarise( - text: string, - userEmail: string, - userAi: UserEmailWithAI, -) { +export async function summarise({ + text, + userEmail, + userAi, +}: { + text: string; + userEmail: string; + userAi: EmailAccountWithAI; +}) { const system = `You are an email assistant. You summarise emails. Summarise each email in a short ~5 word sentence. If you need to summarise a longer email, you can use bullet points. Each bullet should be ~5 words.`; @@ -15,7 +19,7 @@ export async function summarise( const prompt = `Summarise this:\n${text}`; const response = await chatCompletionStream({ - userAi, + userAi: userAi.user, system, prompt, userEmail, diff --git a/apps/web/app/api/ai/summarise/route.ts b/apps/web/app/api/ai/summarise/route.ts index e35315a0b..e6bc10b6a 100644 --- a/apps/web/app/api/ai/summarise/route.ts +++ b/apps/web/app/api/ai/summarise/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server"; import { summarise } from "@/app/api/ai/summarise/controller"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { summariseBody } from "@/app/api/ai/summarise/validation"; import { getSummary } from "@/utils/redis/summary"; import { emailToContent } from "@/utils/mail"; import { getAiUser } from "@/utils/user/get"; -export const POST = withAuth(async (request) => { - const email = request.auth.userEmail; +export const POST = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const json = await request.json(); const body = summariseBody.parse(json); @@ -24,12 +24,16 @@ export const POST = withAuth(async (request) => { const cachedSummary = await getSummary(prompt); if (cachedSummary) return new NextResponse(cachedSummary); - const userAi = await getAiUser({ email }); + const userAi = await getAiUser({ emailAccountId }); if (!userAi) return NextResponse.json({ error: "User not found" }, { status: 404 }); - const stream = await summarise(prompt, email, userAi); + const stream = await summarise({ + text: prompt, + userEmail: userAi.email, + userAi, + }); return stream.toTextStreamResponse(); }); diff --git a/apps/web/app/api/clean/gmail/route.ts b/apps/web/app/api/clean/gmail/route.ts index e62245580..4928aba40 100644 --- a/apps/web/app/api/clean/gmail/route.ts +++ b/apps/web/app/api/clean/gmail/route.ts @@ -14,7 +14,7 @@ import { updateThread } from "@/utils/redis/clean"; const logger = createScopedLogger("api/clean/gmail"); const cleanGmailSchema = z.object({ - email: z.string(), + emailAccountId: z.string(), threadId: z.string(), markDone: z.boolean(), action: z.enum([CleanAction.ARCHIVE, CleanAction.MARK_READ]), @@ -26,7 +26,7 @@ const cleanGmailSchema = z.object({ export type CleanGmailBody = z.infer; async function performGmailAction({ - email, + emailAccountId, threadId, markDone, // labelId, @@ -36,7 +36,7 @@ async function performGmailAction({ action, }: CleanGmailBody) { const account = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { account: { select: { access_token: true, refresh_token: true } }, }, @@ -74,7 +74,7 @@ async function performGmailAction({ }); await saveCleanResult({ - email, + emailAccountId, threadId, markDone, jobId, @@ -82,20 +82,25 @@ async function performGmailAction({ } async function saveCleanResult({ - email, + emailAccountId, threadId, markDone, jobId, }: { - email: string; + emailAccountId: string; threadId: string; markDone: boolean; jobId: string; }) { await Promise.all([ - updateThread({ email, jobId, threadId, update: { status: "completed" } }), + updateThread({ + emailAccountId, + jobId, + threadId, + update: { status: "completed" }, + }), saveToDatabase({ - email, + emailAccountId, threadId, archive: markDone, jobId, @@ -104,19 +109,19 @@ async function saveCleanResult({ } async function saveToDatabase({ - email, + emailAccountId, threadId, archive, jobId, }: { - email: string; + emailAccountId: string; threadId: string; archive: boolean; jobId: string; }) { await prisma.cleanupThread.create({ data: { - emailAccount: { connect: { email } }, + emailAccount: { connect: { id: emailAccountId } }, threadId, archived: archive, job: { connect: { id: jobId } }, diff --git a/apps/web/app/api/clean/history/route.ts b/apps/web/app/api/clean/history/route.ts index 2397a93e5..eaaab9dde 100644 --- a/apps/web/app/api/clean/history/route.ts +++ b/apps/web/app/api/clean/history/route.ts @@ -1,20 +1,21 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; export type CleanHistoryResponse = Awaited>; -async function getCleanHistory({ email }: { email: string }) { +async function getCleanHistory({ emailAccountId }: { emailAccountId: string }) { const result = await prisma.cleanupJob.findMany({ - where: { email }, + where: { emailAccountId }, orderBy: { createdAt: "desc" }, include: { _count: { select: { threads: true } } }, }); return { result }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const result = await getCleanHistory({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const result = await getCleanHistory({ emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/clean/route.ts b/apps/web/app/api/clean/route.ts index fc82187a8..050615504 100644 --- a/apps/web/app/api/clean/route.ts +++ b/apps/web/app/api/clean/route.ts @@ -10,7 +10,7 @@ import { SafeError } from "@/utils/error"; import { createScopedLogger } from "@/utils/logger"; import { aiClean } from "@/utils/ai/clean/ai-clean"; import { getEmailForLLM } from "@/utils/get-email-from-message"; -import { getAiUserWithTokens } from "@/utils/user/get"; +import { getEmailAccountWithAiAndTokens } from "@/utils/user/get"; import { findUnsubscribeLink } from "@/utils/parse/parseHtml.server"; import { getCalendarEventStatus } from "@/utils/parse/calender-event"; import { GmailLabel } from "@/utils/gmail/label"; @@ -20,12 +20,11 @@ import { saveThread, updateThread } from "@/utils/redis/clean"; import { internalDateToDate } from "@/utils/date"; import { CleanAction } from "@prisma/client"; import type { ParsedMessage } from "@/utils/types"; -import { hash } from "@/utils/hash"; const logger = createScopedLogger("api/clean"); const cleanThreadBody = z.object({ - email: z.string(), + emailAccountId: z.string(), threadId: z.string(), markedDoneLabelId: z.string(), processedLabelId: z.string(), @@ -40,12 +39,12 @@ const cleanThreadBody = z.object({ attachment: z.boolean().default(false).nullish(), conversation: z.boolean().default(false).nullish(), }), - labels: z.array(z.object({ id: z.string(), name: z.string() })).optional(), + // labels: z.array(z.object({ id: z.string(), name: z.string() })).optional(), }); export type CleanThreadBody = z.infer; async function cleanThread({ - email, + emailAccountId, threadId, markedDoneLabelId, processedLabelId, @@ -53,29 +52,30 @@ async function cleanThread({ action, instructions, skips, - labels, }: CleanThreadBody) { // 1. get thread with messages // 2. process thread with ai / fixed logic // 3. add to gmail action queue - const user = await getAiUserWithTokens({ email }); + const emailAccount = await getEmailAccountWithAiAndTokens({ + emailAccountId, + }); - if (!user) throw new SafeError("User not found", 404); + if (!emailAccount) throw new SafeError("User not found", 404); - if (!user.tokens) throw new SafeError("No Gmail account found", 404); - if (!user.tokens.access_token || !user.tokens.refresh_token) + if (!emailAccount.tokens) throw new SafeError("No Gmail account found", 404); + if (!emailAccount.tokens.access_token || !emailAccount.tokens.refresh_token) throw new SafeError("No Gmail account found", 404); const gmail = getGmailClient({ - accessToken: user.tokens.access_token, - refreshToken: user.tokens.refresh_token, + accessToken: emailAccount.tokens.access_token, + refreshToken: emailAccount.tokens.refresh_token, }); const messages = await getThreadMessages(threadId, gmail); logger.info("Fetched messages", { - email, + emailAccountId, threadId, messageCount: messages.length, }); @@ -83,17 +83,20 @@ async function cleanThread({ const lastMessage = messages[messages.length - 1]; if (!lastMessage) return; - await saveThread(email, { - threadId, - jobId, - subject: lastMessage.headers.subject, - from: lastMessage.headers.from, - snippet: lastMessage.snippet, - date: internalDateToDate(lastMessage.internalDate), + await saveThread({ + emailAccountId, + thread: { + threadId, + jobId, + subject: lastMessage.headers.subject, + from: lastMessage.headers.from, + snippet: lastMessage.snippet, + date: internalDateToDate(lastMessage.internalDate), + }, }); const publish = getPublish({ - email, + emailAccountId, threadId, markedDoneLabelId, processedLabelId, @@ -204,7 +207,7 @@ async function cleanThread({ // llm check const aiResult = await aiClean({ - user, + emailAccount, messageId: lastMessage.id, messages: messages.map((m) => getEmailForLLM(m)), instructions, @@ -215,14 +218,14 @@ async function cleanThread({ } function getPublish({ - email, + emailAccountId, threadId, markedDoneLabelId, processedLabelId, jobId, action, }: { - email: string; + emailAccountId: string; threadId: string; markedDoneLabelId: string; processedLabelId: string; @@ -240,7 +243,7 @@ function getPublish({ const maxRatePerSecond = Math.ceil(12 / actionCount); const cleanGmailBody: CleanGmailBody = { - email, + emailAccountId, threadId, markDone, action, @@ -251,7 +254,7 @@ function getPublish({ }; logger.info("Publishing to Qstash", { - email, + emailAccountId, threadId, maxRatePerSecond, markDone, @@ -259,11 +262,11 @@ function getPublish({ await Promise.all([ publishToQstash("/api/clean/gmail", cleanGmailBody, { - key: `gmail-action-${hash(email)}`, + key: `gmail-action-${emailAccountId}`, ratePerSecond: maxRatePerSecond, }), updateThread({ - email, + emailAccountId, jobId, threadId, update: { @@ -274,7 +277,7 @@ function getPublish({ }), ]); - logger.info("Published to Qstash", { email, threadId }); + logger.info("Published to Qstash", { emailAccountId, threadId }); }; } diff --git a/apps/web/app/api/google/labels/route.ts b/apps/web/app/api/google/labels/route.ts index 0735f93d9..c365d0bb1 100644 --- a/apps/web/app/api/google/labels/route.ts +++ b/apps/web/app/api/google/labels/route.ts @@ -1,7 +1,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; import { getLabels as getGmailLabels } from "@/utils/gmail/label"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getGmailClientForEmail } from "@/utils/account"; export const dynamic = "force-dynamic"; @@ -16,9 +16,10 @@ async function getLabels(gmail: gmail_v1.Gmail) { return { labels }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const gmail = await getGmailClientForEmail({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const gmail = await getGmailClientForEmail({ emailAccountId }); const labels = await getLabels(gmail); return NextResponse.json(labels); diff --git a/apps/web/app/api/google/messages/attachment/route.ts b/apps/web/app/api/google/messages/attachment/route.ts index 7496db23e..cc7237374 100644 --- a/apps/web/app/api/google/messages/attachment/route.ts +++ b/apps/web/app/api/google/messages/attachment/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getGmailAttachment } from "@/utils/gmail/attachment"; import { getGmailClientForEmail } from "@/utils/account"; @@ -13,9 +13,10 @@ const attachmentQuery = z.object({ // export type AttachmentQuery = z.infer; // export type AttachmentResponse = Awaited>; -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const gmail = await getGmailClientForEmail({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const gmail = await getGmailClientForEmail({ emailAccountId }); const { searchParams } = new URL(request.url); diff --git a/apps/web/app/api/google/messages/batch/route.ts b/apps/web/app/api/google/messages/batch/route.ts index b00c37158..125ed0b9b 100644 --- a/apps/web/app/api/google/messages/batch/route.ts +++ b/apps/web/app/api/google/messages/batch/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getGmailAccessToken } from "@/utils/gmail/client"; import { uniq } from "lodash"; import { getMessagesBatch } from "@/utils/gmail/message"; @@ -19,9 +19,10 @@ export type MessagesBatchResponse = { messages: Awaited>; }; -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const tokens = await getTokens({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const tokens = await getTokens({ emailAccountId }); const accessToken = await getGmailAccessToken(tokens); if (!accessToken.token) diff --git a/apps/web/app/api/google/messages/route.ts b/apps/web/app/api/google/messages/route.ts index 76143a3ce..b832c7f23 100644 --- a/apps/web/app/api/google/messages/route.ts +++ b/apps/web/app/api/google/messages/route.ts @@ -1,9 +1,6 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { getGmailClient } from "@/utils/gmail/client"; +import { NextResponse } from "next/server"; import { queryBatchMessages } from "@/utils/gmail/message"; -import { withAuth, withError } from "@/utils/middleware"; -import { SafeError } from "@/utils/error"; +import { withEmailAccount } from "@/utils/middleware"; import { messageQuerySchema } from "@/app/api/google/messages/validation"; import { createScopedLogger } from "@/utils/logger"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; @@ -17,14 +14,16 @@ export type MessagesResponse = Awaited>; async function getMessages({ query, pageToken, - email, + emailAccountId, + userEmail, }: { query?: string | null; pageToken?: string | null; - email: string; + emailAccountId: string; + userEmail: string; }) { try { - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const { messages, nextPageToken } = await queryBatchMessages(gmail, { query: query?.trim(), @@ -45,11 +44,11 @@ async function getMessages({ // Don't include messages from/to the assistant if ( isAssistantEmail({ - userEmail: email, + userEmail, emailToCheck: message.headers.from, }) || isAssistantEmail({ - userEmail: email, + userEmail, emailToCheck: message.headers.to, }) ) { @@ -67,7 +66,7 @@ async function getMessages({ return { messages: incomingMessages, nextPageToken }; } catch (error) { logger.error("Error getting messages", { - email, + emailAccountId, query, pageToken, error, @@ -76,16 +75,19 @@ async function getMessages({ } } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + const userEmail = request.auth.email; + const { searchParams } = new URL(request.url); const query = searchParams.get("q"); const pageToken = searchParams.get("pageToken"); const r = messageQuerySchema.parse({ q: query, pageToken }); const result = await getMessages({ - email, + emailAccountId, query: r.q, pageToken: r.pageToken, + userEmail, }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/google/threads/[id]/route.ts b/apps/web/app/api/google/threads/[id]/route.ts index b87fb1641..2bd1aeef8 100644 --- a/apps/web/app/api/google/threads/[id]/route.ts +++ b/apps/web/app/api/google/threads/[id]/route.ts @@ -3,7 +3,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; import { parseMessages } from "@/utils/mail"; import { getGmailClientForEmail } from "@/utils/account"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getThread as getGmailThread } from "@/utils/gmail/thread"; export const dynamic = "force-dynamic"; @@ -27,12 +27,13 @@ async function getThread( return { thread: { ...thread, messages } }; } -export const GET = withAuth(async (request, context) => { +export const GET = withEmailAccount(async (request, context) => { + const emailAccountId = request.auth.emailAccountId; + const params = await context.params; const { id } = threadQuery.parse(params); - const email = request.auth.userEmail; - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const { searchParams } = new URL(request.url); const includeDrafts = searchParams.get("includeDrafts") === "true"; diff --git a/apps/web/app/api/google/threads/basic/route.ts b/apps/web/app/api/google/threads/basic/route.ts index 417e33ead..90038caa7 100644 --- a/apps/web/app/api/google/threads/basic/route.ts +++ b/apps/web/app/api/google/threads/basic/route.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import type { gmail_v1 } from "@googleapis/gmail"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getThreads } from "@/utils/gmail/thread"; import { getGmailClientForEmail } from "@/utils/account"; @@ -25,9 +25,10 @@ async function getGetThreads( return threads.threads || []; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const gmail = await getGmailClientForEmail({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const gmail = await getGmailClientForEmail({ emailAccountId }); const { searchParams } = new URL(request.url); const from = searchParams.get("from"); diff --git a/apps/web/app/api/google/threads/batch/route.ts b/apps/web/app/api/google/threads/batch/route.ts index 015a51f63..3055244af 100644 --- a/apps/web/app/api/google/threads/batch/route.ts +++ b/apps/web/app/api/google/threads/batch/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; import { getTokens } from "@/utils/account"; import { getGmailAccessToken } from "@/utils/gmail/client"; @@ -14,8 +14,8 @@ export type ThreadsBatchResponse = Awaited< ReturnType >; -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const { searchParams } = new URL(request.url); const { threadIds, includeDrafts } = requestSchema.parse({ @@ -27,7 +27,7 @@ export const GET = withAuth(async (request) => { return NextResponse.json({ threads: [] } satisfies ThreadsBatchResponse); } - const tokens = await getTokens({ email }); + const tokens = await getTokens({ emailAccountId }); if (!tokens) return NextResponse.json({ error: "Account not found" }); const token = await getGmailAccessToken(tokens); diff --git a/apps/web/app/api/google/threads/controller.ts b/apps/web/app/api/google/threads/controller.ts index eafea87ae..5c0de97ec 100644 --- a/apps/web/app/api/google/threads/controller.ts +++ b/apps/web/app/api/google/threads/controller.ts @@ -19,12 +19,12 @@ export async function getThreads({ query, gmail, accessToken, - email, + emailAccountId, }: { query: ThreadsQuery; gmail: gmail_v1.Gmail; accessToken: string; - email: string; + emailAccountId: string; }) { if (!accessToken) throw new SafeError("Missing access token"); @@ -56,7 +56,7 @@ export async function getThreads({ getThreadsBatch(threadIds, accessToken), // may have been faster not using batch method, but doing 50 getMessages in parallel prisma.executedRule.findMany({ where: { - emailAccountId: email, + emailAccountId, threadId: { in: threadIds }, status: { // TODO probably want to show applied rules here in the future too @@ -88,7 +88,7 @@ export async function getThreads({ messages, snippet: decodeSnippet(thread.snippet), plan, - category: await getCategory({ email, threadId: id }), + category: await getCategory({ emailAccountId, threadId: id }), }; }) || [], ); diff --git a/apps/web/app/api/google/threads/route.ts b/apps/web/app/api/google/threads/route.ts index d210afa86..c286c18cc 100644 --- a/apps/web/app/api/google/threads/route.ts +++ b/apps/web/app/api/google/threads/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getThreads } from "@/app/api/google/threads/controller"; import { threadsQuery } from "@/app/api/google/threads/validation"; import { getGmailAccessToken } from "@/utils/gmail/client"; @@ -10,8 +10,8 @@ export const dynamic = "force-dynamic"; export const maxDuration = 30; -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const { searchParams } = new URL(request.url); const limit = searchParams.get("limit"); @@ -29,7 +29,7 @@ export const GET = withAuth(async (request) => { labelId, }); - const tokens = await getTokens({ email }); + const tokens = await getTokens({ emailAccountId }); if (!tokens) return NextResponse.json({ error: "Account not found" }); const gmail = getGmailClient(tokens); @@ -39,7 +39,7 @@ export const GET = withAuth(async (request) => { const threads = await getThreads({ query, - email, + emailAccountId, gmail, accessToken: token.token, }); diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index 1a06d0053..1cf02b819 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -22,7 +22,7 @@ import { cleanupThreadAIDrafts, } from "@/utils/reply-tracker/draft-tracking"; import type { ParsedMessage } from "@/utils/types"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { formatError } from "@/utils/error"; export async function processHistoryItem( @@ -32,7 +32,7 @@ export async function processHistoryItem( { gmail, email: userEmail, - user, + emailAccount, accessToken, hasColdEmailAccess, hasAutomationRules, @@ -98,7 +98,7 @@ export async function processHistoryItem( return processAssistantEmail({ message, userEmail, - userId: user.userId, + userId: emailAccount.userId, gmail, }); } @@ -116,7 +116,7 @@ export async function processHistoryItem( const isOutbound = message.labelIds?.includes(GmailLabel.SENT); if (isOutbound) { - await handleOutbound(user, message, gmail); + await handleOutbound(emailAccount, message, gmail); return; } @@ -134,7 +134,7 @@ export async function processHistoryItem( } const shouldRunBlocker = shouldRunColdEmailBlocker( - user.coldEmailBlocker, + emailAccount.coldEmailBlocker, hasColdEmailAccess, ); @@ -153,7 +153,7 @@ export async function processHistoryItem( date: internalDateToDate(message.internalDate), }, gmail, - user, + emailAccount, }); if (response.isColdEmail) { @@ -164,7 +164,7 @@ export async function processHistoryItem( // categorize a sender if we haven't already // this is used for category filters in ai rules - if (user.autoCategorizeSenders) { + if (emailAccount.autoCategorizeSenders) { const sender = extractEmailAddress(message.headers.from); const existingSender = await prisma.newsletter.findUnique({ where: { @@ -173,7 +173,7 @@ export async function processHistoryItem( select: { category: true }, }); if (!existingSender?.category) { - await categorizeSender(sender, user, gmail, accessToken); + await categorizeSender(sender, emailAccount, gmail, accessToken); } } @@ -184,7 +184,7 @@ export async function processHistoryItem( gmail, message, rules, - user, + emailAccount, isTest: false, }); } @@ -203,7 +203,7 @@ export async function processHistoryItem( } async function handleOutbound( - user: UserEmailWithAI, + user: EmailAccountWithAI, message: ParsedMessage, gmail: gmail_v1.Gmail, ) { diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index 853cf0ed6..1ec3c1b0f 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -25,18 +25,16 @@ export async function processHistoryForUser( // So we need to convert it to lowercase const email = emailAddress.toLowerCase(); - const emailAccount = await prisma.emailAccount.findFirst({ + const emailAccount = await prisma.emailAccount.findUnique({ where: { email }, select: { + id: true, email: true, userId: true, about: true, lastSyncedHistoryId: true, coldEmailBlocker: true, coldEmailPrompt: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, autoCategorizeSenders: true, account: { select: { @@ -52,6 +50,9 @@ export async function processHistoryForUser( }, user: { select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, premium: { select: { lemonSqueezyRenewsAt: true, @@ -90,11 +91,11 @@ export async function processHistoryForUser( const userHasAiAccess = hasAiAccess( premium.aiAutomationAccess, - emailAccount.aiApiKey, + emailAccount.user.aiApiKey, ); const userHasColdEmailAccess = hasColdEmailAccess( premium.coldEmailBlockerAccess, - emailAccount.aiApiKey, + emailAccount.user.aiApiKey, ); if (!userHasAiAccess && !userHasColdEmailAccess) { @@ -178,7 +179,7 @@ export async function processHistoryForUser( rules: emailAccount.rules, hasColdEmailAccess: userHasColdEmailAccess, hasAiAutomationAccess: userHasAiAccess, - user: emailAccount, + emailAccount, }); } else { logger.info("No history", { diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts index 5cbcec2ab..8c015f273 100644 --- a/apps/web/app/api/google/webhook/types.ts +++ b/apps/web/app/api/google/webhook/types.ts @@ -1,6 +1,6 @@ import type { gmail_v1 } from "@googleapis/gmail"; import type { RuleWithActionsAndCategories } from "@/utils/types"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailAccount, User } from "@prisma/client"; export type ProcessHistoryOptions = { @@ -12,9 +12,9 @@ export type ProcessHistoryOptions = { hasAutomationRules: boolean; hasColdEmailAccess: boolean; hasAiAutomationAccess: boolean; - user: Pick< + emailAccount: Pick< EmailAccount, "coldEmailPrompt" | "coldEmailBlocker" | "autoCategorizeSenders" > & - UserEmailWithAI; + EmailAccountWithAI; }; diff --git a/apps/web/app/api/lemon-squeezy/webhook/route.ts b/apps/web/app/api/lemon-squeezy/webhook/route.ts index 9430f5877..187a3cc67 100644 --- a/apps/web/app/api/lemon-squeezy/webhook/route.ts +++ b/apps/web/app/api/lemon-squeezy/webhook/route.ts @@ -18,7 +18,7 @@ import { upgradedToPremium, } from "@inboxzero/loops"; import { SafeError } from "@/utils/error"; -import { getSubscriptionTier } from "@/app/(app)/[account]/premium/config"; +import { getSubscriptionTier } from "@/app/(app)/[emailAccountId]/premium/config"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("Lemon Squeezy Webhook"); diff --git a/apps/web/app/api/user/accounts/route.ts b/apps/web/app/api/user/accounts/route.ts index c90c8f2d2..9921240e3 100644 --- a/apps/web/app/api/user/accounts/route.ts +++ b/apps/web/app/api/user/accounts/route.ts @@ -3,12 +3,15 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { withError } from "@/utils/middleware"; -export type GetAccountsResponse = Awaited>; +export type GetEmailAccountsResponse = Awaited< + ReturnType +>; -async function getAccounts({ userId }: { userId: string }) { - const accounts = await prisma.emailAccount.findMany({ +async function getEmailAccounts({ userId }: { userId: string }) { + const emailAccounts = await prisma.emailAccount.findMany({ where: { userId }, select: { + id: true, email: true, accountId: true, user: { select: { name: true, image: true } }, @@ -18,7 +21,7 @@ async function getAccounts({ userId }: { userId: string }) { }, }); - return { accounts }; + return { emailAccounts }; } export const GET = withError(async () => { @@ -28,6 +31,6 @@ export const GET = withError(async () => { if (!userId) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - const result = await getAccounts({ userId }); + const result = await getEmailAccounts({ userId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/bulk-archive/route.ts b/apps/web/app/api/user/bulk-archive/route.ts index e0ab59bd9..8f9f638b3 100644 --- a/apps/web/app/api/user/bulk-archive/route.ts +++ b/apps/web/app/api/user/bulk-archive/route.ts @@ -7,7 +7,7 @@ import { labelThread, } from "@/utils/gmail/label"; import { sleep } from "@/utils/sleep"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getThreads } from "@/utils/gmail/thread"; import { getGmailClientForEmail } from "@/utils/account"; @@ -36,7 +36,7 @@ async function bulkArchive(body: BulkArchiveBody, gmail: gmail_v1.Gmail) { for (const thread of threads || []) { await labelThread({ gmail, - threadId: thread.id!, + threadId: thread.id, addLabelIds: [archivedLabel.id], removeLabelIds: [GmailLabel.INBOX], }); @@ -49,9 +49,10 @@ async function bulkArchive(body: BulkArchiveBody, gmail: gmail_v1.Gmail) { return { count: threads?.length || 0 }; } -export const POST = withAuth(async (request) => { - const email = request.auth.userEmail; - const gmail = await getGmailClientForEmail({ email }); +export const POST = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const gmail = await getGmailClientForEmail({ emailAccountId }); const json = await request.json(); const body = bulkArchiveBody.parse(json); diff --git a/apps/web/app/api/user/categories/route.ts b/apps/web/app/api/user/categories/route.ts index 6e332692e..9ad689d63 100644 --- a/apps/web/app/api/user/categories/route.ts +++ b/apps/web/app/api/user/categories/route.ts @@ -1,16 +1,16 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getUserCategories } from "@/utils/category.server"; export type UserCategoriesResponse = Awaited>; -async function getCategories({ email }: { email: string }) { - const result = await getUserCategories({ email }); +async function getCategories({ emailAccountId }: { emailAccountId: string }) { + const result = await getUserCategories({ emailAccountId }); return { result }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const result = await getCategories({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + const result = await getCategories({ emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts index 7edce074b..08d30021c 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const aiCategorizeSendersSchema = z.object({ - email: z.string(), + emailAccountId: z.string(), senders: z.array(z.string()), }); export type AiCategorizeSenders = z.infer; diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts index c8f78c475..c703d443b 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts @@ -34,18 +34,21 @@ export async function handleBatchRequest( async function handleBatchInternal(request: Request) { const json = await request.json(); const body = aiCategorizeSendersSchema.parse(json); - const { email, senders } = body; + const { emailAccountId, senders } = body; - logger.trace("Handle batch request", { email, senders: senders.length }); + logger.trace("Handle batch request", { + emailAccountId, + senders: senders.length, + }); - const userResult = await validateUserAndAiAccess({ email }); + const userResult = await validateUserAndAiAccess({ emailAccountId }); const { emailAccount } = userResult; - const categoriesResult = await getCategories({ email }); + const categoriesResult = await getCategories({ emailAccountId }); const { categories } = categoriesResult; const emailAccountWithAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { account: { select: { @@ -90,7 +93,7 @@ async function handleBatchInternal(request: Request) { // 2. categorize senders with ai const results = await categorizeWithAi({ - user: emailAccount, + emailAccount, sendersWithEmails, categories, }); @@ -131,7 +134,7 @@ async function handleBatchInternal(request: Request) { // } await saveCategorizationProgress({ - email, + emailAccountId, incrementCompleted: senders.length, }); diff --git a/apps/web/app/api/user/draft-actions/route.ts b/apps/web/app/api/user/draft-actions/route.ts index bf430bf9c..39ea218c8 100644 --- a/apps/web/app/api/user/draft-actions/route.ts +++ b/apps/web/app/api/user/draft-actions/route.ts @@ -1,22 +1,22 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { ActionType } from "@prisma/client"; export type DraftActionsResponse = Awaited>; -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; - const response = await getData({ email }); + const response = await getData({ emailAccountId }); return NextResponse.json(response); }); -async function getData({ email }: { email: string }) { +async function getData({ emailAccountId }: { emailAccountId: string }) { const executedActions = await prisma.executedAction.findMany({ where: { - executedRule: { emailAccountId: email }, + executedRule: { emailAccountId }, type: ActionType.DRAFT_EMAIL, }, select: { diff --git a/apps/web/app/api/user/group/[groupId]/items/route.ts b/apps/web/app/api/user/group/[groupId]/items/route.ts index 6d1382b62..affbd2569 100644 --- a/apps/web/app/api/user/group/[groupId]/items/route.ts +++ b/apps/web/app/api/user/group/[groupId]/items/route.ts @@ -1,18 +1,18 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; export type GroupItemsResponse = Awaited>; async function getGroupItems({ - email, + emailAccountId, groupId, }: { - email: string; + emailAccountId: string; groupId: string; }) { const group = await prisma.group.findUnique({ - where: { id: groupId, emailAccountId: email }, + where: { id: groupId, emailAccountId }, select: { name: true, prompt: true, @@ -23,13 +23,13 @@ async function getGroupItems({ return { group }; } -export const GET = withAuth(async (request, { params }) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request, { params }) => { + const emailAccountId = request.auth.emailAccountId; const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Group id required" }); - const result = await getGroupItems({ email, groupId }); + const result = await getGroupItems({ emailAccountId, groupId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/group/[groupId]/messages/controller.ts b/apps/web/app/api/user/group/[groupId]/messages/controller.ts index 5e1d56b67..eba90c135 100644 --- a/apps/web/app/api/user/group/[groupId]/messages/controller.ts +++ b/apps/web/app/api/user/group/[groupId]/messages/controller.ts @@ -7,7 +7,7 @@ import { findMatchingGroupItem } from "@/utils/group/find-matching-group"; import { parseMessage } from "@/utils/mail"; import { extractEmailAddress } from "@/utils/email"; import { type GroupItem, GroupItemType } from "@prisma/client"; -import type { MessageWithGroupItem } from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/types"; +import type { MessageWithGroupItem } from "@/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/types"; import { SafeError } from "@/utils/error"; const PAGE_SIZE = 20; diff --git a/apps/web/app/api/user/group/[groupId]/rules/route.ts b/apps/web/app/api/user/group/[groupId]/rules/route.ts index ec5a6856c..12ff7d8c8 100644 --- a/apps/web/app/api/user/group/[groupId]/rules/route.ts +++ b/apps/web/app/api/user/group/[groupId]/rules/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { SafeError } from "@/utils/error"; export type GroupRulesResponse = Awaited>; @@ -28,13 +28,13 @@ async function getGroupRules({ return { rule: groupWithRules.rule }; } -export const GET = withAuth(async (request, { params }) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request, { params }) => { + const emailAccountId = request.auth.emailAccountId; const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Group id required" }); - const result = await getGroupRules({ emailAccountId: email, groupId }); + const result = await getGroupRules({ emailAccountId, groupId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/group/route.ts b/apps/web/app/api/user/group/route.ts index 5c732adf2..165fccba0 100644 --- a/apps/web/app/api/user/group/route.ts +++ b/apps/web/app/api/user/group/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; export type GroupsResponse = Awaited>; -async function getGroups({ email }: { email: string }) { +async function getGroups({ emailAccountId }: { emailAccountId: string }) { const groups = await prisma.group.findMany({ - where: { emailAccountId: email }, + where: { emailAccountId }, select: { id: true, name: true, @@ -17,10 +17,8 @@ async function getGroups({ email }: { email: string }) { return { groups }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - - const result = await getGroups({ email }); - +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + const result = await getGroups({ emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/me/route.ts b/apps/web/app/api/user/me/route.ts index 4deb8e1af..3a436494e 100644 --- a/apps/web/app/api/user/me/route.ts +++ b/apps/web/app/api/user/me/route.ts @@ -11,15 +11,15 @@ async function getUser({ email }: { email: string }) { where: { email }, select: { userId: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, statsEmailFrequency: true, summaryEmailFrequency: true, coldEmailBlocker: true, coldEmailPrompt: true, user: { select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, premium: { select: { lemonSqueezyCustomerId: true, diff --git a/apps/web/app/api/user/no-reply/route.ts b/apps/web/app/api/user/no-reply/route.ts index d4f60882a..730fe7897 100644 --- a/apps/web/app/api/user/no-reply/route.ts +++ b/apps/web/app/api/user/no-reply/route.ts @@ -1,17 +1,24 @@ import { NextResponse } from "next/server"; import type { gmail_v1 } from "@googleapis/gmail"; -import { getGmailClient } from "@/utils/gmail/client"; import { type MessageWithPayload, isDefined } from "@/utils/types"; import { parseMessage } from "@/utils/mail"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getThread } from "@/utils/gmail/thread"; import { getMessages } from "@/utils/gmail/message"; import { getGmailClientForEmail } from "@/utils/account"; export type NoReplyResponse = Awaited>; -async function getNoReply(options: { email: string; gmail: gmail_v1.Gmail }) { - const sentEmails = await getMessages(options.gmail, { +async function getNoReply({ + emailAccountId, + gmail, + userEmail, +}: { + emailAccountId: string; + gmail: gmail_v1.Gmail; + userEmail: string; +}) { + const sentEmails = await getMessages(gmail, { query: "in:sent", maxResults: 50, }); @@ -19,13 +26,13 @@ async function getNoReply(options: { email: string; gmail: gmail_v1.Gmail }) { const sentEmailsWithThreads = ( await Promise.all( sentEmails.messages?.map(async (message) => { - const thread = await getThread(message.threadId || "", options.gmail); + const thread = await getThread(message.threadId || "", gmail); const lastMessage = thread.messages?.[thread.messages?.length - 1]; const lastMessageFrom = lastMessage?.payload?.headers?.find( (header) => header.name?.toLowerCase() === "from", )?.value; - const isSentByUser = lastMessageFrom?.includes(options.email); + const isSentByUser = lastMessageFrom?.includes(userEmail); if (isSentByUser) return { @@ -45,10 +52,12 @@ async function getNoReply(options: { email: string; gmail: gmail_v1.Gmail }) { return sentEmailsWithThreads; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const gmail = await getGmailClientForEmail({ email }); - const result = await getNoReply({ email, gmail }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + const userEmail = request.auth.email; + + const gmail = await getGmailClientForEmail({ emailAccountId }); + const result = await getNoReply({ emailAccountId, gmail, userEmail }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/planned/history/route.ts b/apps/web/app/api/user/planned/history/route.ts index 1abf1d4c2..4da171816 100644 --- a/apps/web/app/api/user/planned/history/route.ts +++ b/apps/web/app/api/user/planned/history/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withAuth, withEmailAccount } from "@/utils/middleware"; import { ExecutedRuleStatus } from "@prisma/client"; import { getExecutedRules } from "@/app/api/user/planned/get-executed-rules"; @@ -7,16 +7,18 @@ export const dynamic = "force-dynamic"; export type PlanHistoryResponse = Awaited>; -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + const url = new URL(request.url); const page = Number.parseInt(url.searchParams.get("page") || "1"); const ruleId = url.searchParams.get("ruleId") || "all"; + const messages = await getExecutedRules({ status: ExecutedRuleStatus.APPLIED, page, + emailAccountId, ruleId, - emailAccountId: email, }); return NextResponse.json(messages); }); diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts index 732ef832d..8b754b55a 100644 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ b/apps/web/app/api/user/rules/[id]/example/controller.ts @@ -4,7 +4,7 @@ import { getMessage, getMessages } from "@/utils/gmail/message"; import type { MessageWithGroupItem, RuleWithGroup, -} from "@/app/(app)/[account]/automation/rule/[ruleId]/examples/types"; +} from "@/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/types"; import { matchesStaticRule } from "@/utils/ai/choose-rule/match-rules"; import { fetchPaginatedMessages } from "@/app/api/user/group/[groupId]/messages/controller"; import { diff --git a/apps/web/app/api/user/rules/[id]/example/route.ts b/apps/web/app/api/user/rules/[id]/example/route.ts index dd4efc7f2..e627758eb 100644 --- a/apps/web/app/api/user/rules/[id]/example/route.ts +++ b/apps/web/app/api/user/rules/[id]/example/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { fetchExampleMessages } from "@/app/api/user/rules/[id]/example/controller"; import { SafeError } from "@/utils/error"; import { getGmailClientForEmail } from "@/utils/account"; @@ -9,32 +9,32 @@ export type ExamplesResponse = Awaited>; async function getExamples({ ruleId, - email, + emailAccountId, }: { ruleId: string; - email: string; + emailAccountId: string; }) { const rule = await prisma.rule.findUnique({ - where: { id: ruleId, emailAccountId: email }, + where: { id: ruleId, emailAccountId }, include: { group: { include: { items: true } } }, }); if (!rule) throw new SafeError("Rule not found"); - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const exampleMessages = await fetchExampleMessages(rule, gmail); return exampleMessages; } -export const GET = withAuth(async (request, { params }) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request, { params }) => { + const emailAccountId = request.auth.emailAccountId; const { id } = await params; if (!id) return NextResponse.json({ error: "Missing rule id" }); - const result = await getExamples({ ruleId: id, email }); + const result = await getExamples({ ruleId: id, emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/rules/[id]/route.ts b/apps/web/app/api/user/rules/[id]/route.ts index 640c8df01..4f8c08bdc 100644 --- a/apps/web/app/api/user/rules/[id]/route.ts +++ b/apps/web/app/api/user/rules/[id]/route.ts @@ -1,23 +1,29 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; export type RuleResponse = Awaited>; -async function getRule({ ruleId, email }: { ruleId: string; email: string }) { +async function getRule({ + ruleId, + emailAccountId, +}: { + ruleId: string; + emailAccountId: string; +}) { const rule = await prisma.rule.findUnique({ - where: { id: ruleId, emailAccountId: email }, + where: { id: ruleId, emailAccountId }, }); return { rule }; } -export const GET = withAuth(async (request, { params }) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request, { params }) => { + const emailAccountId = request.auth.emailAccountId; const { id } = await params; if (!id) return NextResponse.json({ error: "Missing rule id" }); - const result = await getRule({ ruleId: id, email }); + const result = await getRule({ ruleId: id, emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/rules/prompt/route.ts b/apps/web/app/api/user/rules/prompt/route.ts index ebe2420b9..2a185d21f 100644 --- a/apps/web/app/api/user/rules/prompt/route.ts +++ b/apps/web/app/api/user/rules/prompt/route.ts @@ -1,20 +1,20 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import prisma from "@/utils/prisma"; export type RulesPromptResponse = Awaited>; -async function getRulesPrompt(options: { email: string }) { +async function getRulesPrompt({ emailAccountId }: { emailAccountId: string }) { return await prisma.emailAccount.findUnique({ - where: { email: options.email }, + where: { id: emailAccountId }, select: { rulesPrompt: true }, }); } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; - const result = await getRulesPrompt({ email }); + const result = await getRulesPrompt({ emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index 59ca29e4a..0787ded1e 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -69,10 +69,10 @@ function getTypeFilters(types: NewsletterStatsQuery["types"]) { async function getNewslettersTinybird( options: { emailAccountId: string } & NewsletterStatsQuery, ) { - const emailAccountId = options.emailAccountId; + const { emailAccountId } = options; const types = getTypeFilters(options.types); - const gmail = await getGmailClientForEmail({ email: emailAccountId }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const [newsletterCounts, autoArchiveFilters, userNewsletters] = await Promise.all([ @@ -234,7 +234,13 @@ function getOrderByClause(orderBy: string): string { } export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; + const emailAccountId = request.auth.emailAccountId; + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account ID is required", isKnownError: true }, + { status: 403 }, + ); + } const { searchParams } = new URL(request.url); const params = newsletterStatsQuery.parse({ @@ -250,7 +256,7 @@ export const GET = withAuth(async (request) => { const result = await getNewslettersTinybird({ ...params, - emailAccountId: email, + emailAccountId, }); return NextResponse.json(result); diff --git a/apps/web/app/api/v1/reply-tracker/route.ts b/apps/web/app/api/v1/reply-tracker/route.ts index ac1c8f270..4f8441627 100644 --- a/apps/web/app/api/v1/reply-tracker/route.ts +++ b/apps/web/app/api/v1/reply-tracker/route.ts @@ -7,7 +7,7 @@ import { } from "./validation"; import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth"; import { ThreadTrackerType } from "@prisma/client"; -import { getPaginatedThreadTrackers } from "@/app/(app)/[account]/reply-zero/fetch-trackers"; +import { getPaginatedThreadTrackers } from "@/app/(app)/[emailAccountId]/reply-zero/fetch-trackers"; import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; import { isDefined } from "@/utils/types"; import { getEmailAccountId } from "@/app/api/v1/helpers"; diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 1e4aa81f6..60d19bd6a 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -21,9 +21,9 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { useAccounts } from "@/hooks/useAccounts"; -import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; +import type { GetEmailAccountsResponse } from "@/app/api/user/accounts/route"; import { useModifierKey } from "@/hooks/useModifierKey"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; export function AccountSwitcher() { @@ -35,7 +35,7 @@ export function AccountSwitcher() { export function AccountSwitcherInternal({ accounts, }: { - accounts: GetAccountsResponse["accounts"]; + accounts: GetEmailAccountsResponse["accounts"]; }) { const { isMobile } = useSidebar(); const { symbol: modifierSymbol } = useModifierKey(); @@ -141,7 +141,7 @@ function ProfileImage({ } function useAccountHotkeys( - accounts: GetAccountsResponse["accounts"], + accounts: GetEmailAccountsResponse["accounts"], getHref: (accountId: string) => string, ) { const router = useRouter(); diff --git a/apps/web/components/ActionButtons.tsx b/apps/web/components/ActionButtons.tsx index 7a4023a64..f81b1474c 100644 --- a/apps/web/components/ActionButtons.tsx +++ b/apps/web/components/ActionButtons.tsx @@ -9,7 +9,7 @@ import { ButtonGroup } from "@/components/ButtonGroup"; import { LoadingMiniSpinner } from "@/components/Loading"; import { getGmailUrl } from "@/utils/url"; import { onTrashThread } from "@/utils/actions/client"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function ActionButtons({ threadId, diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 0e56d5e25..28f9ed8bb 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -47,7 +47,7 @@ import { } from "@/store/archive-sender-queue"; import { getGmailSearchUrl, getGmailUrl } from "@/utils/url"; import { MessageText } from "@/components/Typography"; -import { CreateCategoryDialog } from "@/app/(app)/[account]/smart-categories/CreateCategoryButton"; +import { CreateCategoryDialog } from "@/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton"; import { DropdownMenu, DropdownMenuContent, @@ -57,7 +57,7 @@ import { import type { CategoryWithRules } from "@/utils/category.server"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { CategorySelect } from "@/components/CategorySelect"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; const COLUMNS = 4; diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index 496ddee9a..4adc82827 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -11,10 +11,10 @@ import { isPremium, } from "@/utils/premium"; import { Tooltip } from "@/components/Tooltip"; -import { usePremiumModal } from "@/app/(app)/[account]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; import { PremiumTier } from "@prisma/client"; import { useUser } from "@/hooks/useUser"; -import { businessTierName } from "@/app/(app)/[account]/premium/config"; +import { businessTierName } from "@/app/(app)/[emailAccountId]/premium/config"; export function usePremium() { const swrResponse = useUser(); diff --git a/apps/web/components/email-list/EmailList.tsx b/apps/web/components/email-list/EmailList.tsx index acf9c945c..858092eca 100644 --- a/apps/web/components/email-list/EmailList.tsx +++ b/apps/web/components/email-list/EmailList.tsx @@ -31,7 +31,7 @@ import { deleteEmails, markReadThreads, } from "@/store/archive-queue"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function List({ emails, diff --git a/apps/web/components/email-list/EmailMessage.tsx b/apps/web/components/email-list/EmailMessage.tsx index 7ac9639bf..dfd98b250 100644 --- a/apps/web/components/email-list/EmailMessage.tsx +++ b/apps/web/components/email-list/EmailMessage.tsx @@ -8,13 +8,13 @@ import { import { Tooltip } from "@/components/Tooltip"; import { extractNameFromEmail } from "@/utils/email"; import { formatShortDate } from "@/utils/date"; -import { ComposeEmailFormLazy } from "@/app/(app)/[account]/compose/ComposeEmailFormLazy"; +import { ComposeEmailFormLazy } from "@/app/(app)/[emailAccountId]/compose/ComposeEmailFormLazy"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import type { ParsedMessage } from "@/utils/types"; import { forwardEmailHtml, forwardEmailSubject } from "@/utils/gmail/forward"; import { extractEmailReply } from "@/utils/parse/extract-reply.client"; -import type { ReplyingToEmail } from "@/app/(app)/[account]/compose/ComposeEmailForm"; +import type { ReplyingToEmail } from "@/app/(app)/[emailAccountId]/compose/ComposeEmailForm"; import { createReplyContent } from "@/utils/gmail/reply"; import { cn } from "@/utils"; import { generateNudgeReplyAction } from "@/utils/actions/generate-reply"; @@ -24,7 +24,7 @@ import { HtmlEmail, PlainEmail } from "@/components/email-list/EmailContents"; import { EmailAttachments } from "@/components/email-list/EmailAttachments"; import { Loading } from "@/components/Loading"; import { MessageText } from "@/components/Typography"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function EmailMessage({ message, diff --git a/apps/web/components/email-list/PlanActions.tsx b/apps/web/components/email-list/PlanActions.tsx index fbb382206..ed83c1ee1 100644 --- a/apps/web/components/email-list/PlanActions.tsx +++ b/apps/web/components/email-list/PlanActions.tsx @@ -6,7 +6,7 @@ import { Tooltip } from "@/components/Tooltip"; import type { Executing, Thread } from "@/components/email-list/types"; import { cn } from "@/utils"; import { approvePlanAction, rejectPlanAction } from "@/utils/actions/ai-rule"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function useExecutePlan(refetch: () => void) { const [executingPlan, setExecutingPlan] = useState({}); diff --git a/apps/web/hooks/useAccounts.ts b/apps/web/hooks/useAccounts.ts index bd80851a8..772ea3504 100644 --- a/apps/web/hooks/useAccounts.ts +++ b/apps/web/hooks/useAccounts.ts @@ -1,8 +1,8 @@ import useSWR from "swr"; -import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; +import type { GetEmailAccountsResponse } from "@/app/api/user/accounts/route"; export function useAccounts() { - return useSWR("/api/user/accounts", { + return useSWR("/api/user/accounts", { revalidateOnFocus: false, }); } diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index dd4e85359..398a77059 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -13,7 +13,7 @@ model Account { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - userId String @unique // `@unique` was added here. It's not part of the original schema. May remove this in the future + userId String type String provider String providerAccountId String @@ -96,7 +96,8 @@ model User { // Migrating over to the new settings model. Currently most settings are in the User model, but will be moved to this model in the future. model EmailAccount { - email String @id + id String @id @default(cuid()) + email String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt writingStyle String? @@ -107,9 +108,10 @@ model EmailAccount { lastSyncedHistoryId String? behaviorProfile Json? - aiProvider String? - aiModel String? - aiApiKey String? + // TODO: move these into user level again. run migratio to move over data + aiProvider String? // deprecated + aiModel String? // deprecated + aiApiKey String? // deprecated statsEmailFrequency Frequency @default(WEEKLY) summaryEmailFrequency Frequency @default(WEEKLY) lastSummaryEmailAt DateTime? @@ -203,7 +205,7 @@ model Label { description String? // used in prompts enabled Boolean @default(true) emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) @@unique([gmailLabelId, emailAccountId]) @@unique([name, emailAccountId]) @@ -219,7 +221,7 @@ model Rule { automate Boolean @default(false) // if disabled, user must approve to execute runOnThreads Boolean @default(false) // if disabled, only runs on individual emails emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) executedRules ExecutedRule[] @@ -286,7 +288,7 @@ model ExecutedRule { rule Rule? @relation(fields: [ruleId], references: [id]) emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) actionItems ExecutedAction[] @@ -331,7 +333,7 @@ model Group { prompt String? items GroupItem[] emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) rule Rule? @@unique([name, emailAccountId]) @@ -356,7 +358,7 @@ model Category { name String description String? emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) emailSenders Newsletter[] rules Rule[] @@ -379,7 +381,7 @@ model Newsletter { lastAnalyzedAt DateTime? emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) categoryId String? category Category? @relation(fields: [categoryId], references: [id]) @@ -399,7 +401,7 @@ model ColdEmail { reason String? emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) @@unique([emailAccountId, fromEmail]) @@index([emailAccountId, status]) @@ -423,7 +425,7 @@ model EmailMessage { inbox Boolean emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) @@unique([emailAccountId, threadId, messageId]) @@index([emailAccountId, threadId]) @@ -442,7 +444,7 @@ model ThreadTracker { type ThreadTrackerType emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) @@unique([emailAccountId, threadId, messageId]) @@index([emailAccountId, resolved]) @@ -463,8 +465,8 @@ model CleanupJob { skipReceipt Boolean? skipAttachment Boolean? skipConversation Boolean? - email String - emailAccount EmailAccount @relation(fields: [email], references: [email], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) threads CleanupThread[] } @@ -477,7 +479,7 @@ model CleanupThread { jobId String job CleanupJob @relation(fields: [jobId], references: [id], onDelete: Cascade) emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) } model Knowledge { @@ -487,7 +489,7 @@ model Knowledge { title String content String - emailAccount EmailAccount? @relation(fields: [emailAccountId], references: [email]) + emailAccount EmailAccount? @relation(fields: [emailAccountId], references: [id]) emailAccountId String? @@unique([emailAccountId, title]) @@ -512,7 +514,7 @@ model EmailToken { createdAt DateTime @default(now()) token String @unique emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [email], onDelete: Cascade) + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) expiresAt DateTime // action EmailTokenAction @default(UNSUBSCRIBE) } diff --git a/apps/web/providers/ComposeModalProvider.tsx b/apps/web/providers/ComposeModalProvider.tsx index b228fd92c..8ca07fe20 100644 --- a/apps/web/providers/ComposeModalProvider.tsx +++ b/apps/web/providers/ComposeModalProvider.tsx @@ -2,7 +2,7 @@ import { createContext, useContext } from "react"; import { useModal } from "@/hooks/useModal"; -import { ComposeEmailFormLazy } from "@/app/(app)/[account]/compose/ComposeEmailFormLazy"; +import { ComposeEmailFormLazy } from "@/app/(app)/[emailAccountId]/compose/ComposeEmailFormLazy"; import { Dialog, DialogContent, diff --git a/apps/web/providers/AccountProvider.tsx b/apps/web/providers/EmailAccountProvider.tsx similarity index 55% rename from apps/web/providers/AccountProvider.tsx rename to apps/web/providers/EmailAccountProvider.tsx index 1a68b34f9..cfc48387a 100644 --- a/apps/web/providers/AccountProvider.tsx +++ b/apps/web/providers/EmailAccountProvider.tsx @@ -2,9 +2,9 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { useParams } from "next/navigation"; -import type { GetAccountsResponse } from "@/app/api/user/accounts/route"; +import type { GetEmailAccountsResponse } from "@/app/api/user/accounts/route"; -type Account = GetAccountsResponse["accounts"][number]; +type Account = GetEmailAccountsResponse["emailAccounts"][number]; type Context = { account: Account | undefined; @@ -12,13 +12,15 @@ type Context = { isLoading: boolean; }; -const AccountContext = createContext(undefined); +const EmailAccountContext = createContext(undefined); -export function AccountProvider({ children }: { children: React.ReactNode }) { +export function EmailAccountProvider({ + children, +}: { children: React.ReactNode }) { const params = useParams<{ account: string | undefined }>(); // TODO: throw an error if account is not defined? - const accountId = params.account; - const [data, setData] = useState(null); + const emailAccountId = params.account; + const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -28,8 +30,8 @@ export function AccountProvider({ children }: { children: React.ReactNode }) { // This is the simplest fix const response = await fetch("/api/user/accounts"); if (response.ok) { - const accountData: GetAccountsResponse = await response.json(); - setData(accountData); + const emailAccounts: GetEmailAccountsResponse = await response.json(); + setData(emailAccounts); } } catch (error) { console.error("Error fetching accounts:", error); @@ -42,29 +44,31 @@ export function AccountProvider({ children }: { children: React.ReactNode }) { }, []); const account = useMemo(() => { - if (data?.accounts) { + if (data?.emailAccounts) { const currentAccount = - data.accounts.find((acc) => acc.accountId === accountId) ?? - data.accounts[0]; + data.emailAccounts.find((acc) => acc.id === emailAccountId) ?? + data.emailAccounts[0]; return currentAccount; } - }, [data, accountId]); + }, [data, emailAccountId]); return ( - {children} - + ); } export function useAccount() { - const context = useContext(AccountContext); + const context = useContext(EmailAccountContext); if (context === undefined) { - throw new Error("useAccount must be used within an AccountProvider"); + throw new Error( + "useEmailAccount must be used within an EmailAccountProvider", + ); } return context; diff --git a/apps/web/providers/GlobalProviders.tsx b/apps/web/providers/GlobalProviders.tsx index 45513274f..9d07452d7 100644 --- a/apps/web/providers/GlobalProviders.tsx +++ b/apps/web/providers/GlobalProviders.tsx @@ -4,7 +4,7 @@ import { SessionProvider } from "@/providers/SessionProvider"; import { SWRProvider } from "@/providers/SWRProvider"; import { StatLoaderProvider } from "@/providers/StatLoaderProvider"; import { ComposeModalProvider } from "@/providers/ComposeModalProvider"; -import { AccountProvider } from "@/providers/AccountProvider"; +import { AccountProvider } from "@/providers/EmailAccountProvider"; export function GlobalProviders(props: { children: React.ReactNode }) { return ( diff --git a/apps/web/providers/SWRProvider.tsx b/apps/web/providers/SWRProvider.tsx index 09c69cf91..049e6b011 100644 --- a/apps/web/providers/SWRProvider.tsx +++ b/apps/web/providers/SWRProvider.tsx @@ -3,7 +3,8 @@ import { useCallback, useState, createContext, useMemo } from "react"; import { SWRConfig, mutate } from "swr"; import { captureException } from "@/utils/error"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; // https://swr.vercel.app/docs/error-handling#status-code-and-error-object const fetcher = async (url: string, init?: RequestInit | undefined) => { @@ -72,7 +73,7 @@ export const SWRProvider = (props: { children: React.ReactNode }) => { const headers = new Headers(init?.headers); if (account?.accountId) { - headers.set("X-Account-ID", account.accountId); + headers.set(EMAIL_ACCOUNT_HEADER, account.accountId); } const newInit = { ...init, headers }; diff --git a/apps/web/store/QueueInitializer.tsx b/apps/web/store/QueueInitializer.tsx index 904bd5a76..67bf4d8bb 100644 --- a/apps/web/store/QueueInitializer.tsx +++ b/apps/web/store/QueueInitializer.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { processQueue, useQueueState } from "@/store/archive-queue"; -import { useAccount } from "@/providers/AccountProvider"; +import { useAccount } from "@/providers/EmailAccountProvider"; let isInitialized = false; diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index a62522ce7..c1255f6cb 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -15,7 +15,7 @@ import { } from "@/app/api/lemon-squeezy/api"; import { PremiumTier } from "@prisma/client"; import { ONE_MONTH_MS, ONE_YEAR_MS } from "@/utils/date"; -import { getVariantId } from "@/app/(app)/[account]/premium/config"; +import { getVariantId } from "@/app/(app)/[emailAccountId]/premium/config"; import { actionClientUser, adminActionClient, diff --git a/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts index 82addf013..9ce9a16f6 100644 --- a/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts +++ b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; import { isDefined } from "@/utils/types"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { Category } from "@prisma/client"; import { formatCategoriesForPrompt } from "@/utils/ai/categorize-sender/format-categories"; import { createScopedLogger } from "@/utils/logger"; @@ -23,11 +23,11 @@ const categorizeSendersSchema = z.object({ }); export async function aiCategorizeSenders({ - user, + emailAccount, senders, categories, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; senders: { emailAddress: string; emails: { subject: string; snippet: string }[]; @@ -90,11 +90,11 @@ ${formatCategoriesForPrompt(categories)} logger.trace("Categorize senders", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema: categorizeSendersSchema, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Categorize senders bulk", }); diff --git a/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts b/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts index 74dfdcdab..1604593db 100644 --- a/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts +++ b/apps/web/utils/ai/categorize-sender/ai-categorize-single-sender.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { Category } from "@prisma/client"; import { formatCategoriesForPrompt } from "@/utils/ai/categorize-sender/format-categories"; import { createScopedLogger } from "@/utils/logger"; @@ -16,12 +16,12 @@ const categorizeSenderSchema = z.object({ }); export async function aiCategorizeSender({ - user, + emailAccount, sender, previousEmails, categories, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; sender: string; previousEmails: { subject: string; snippet: string }[]; categories: Pick[]; @@ -58,11 +58,11 @@ ${formatCategoriesForPrompt(categories)} logger.trace("aiCategorizeSender", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema: categorizeSenderSchema, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Categorize sender", }); diff --git a/apps/web/utils/ai/choose-rule/ai-choose-args.ts b/apps/web/utils/ai/choose-rule/ai-choose-args.ts index 195bf15c8..8966b03b5 100644 --- a/apps/web/utils/ai/choose-rule/ai-choose-args.ts +++ b/apps/web/utils/ai/choose-rule/ai-choose-args.ts @@ -3,7 +3,7 @@ import { InvalidToolArgumentsError } from "ai"; import { chatCompletionTools, withRetry } from "@/utils/llms"; import { stringifyEmail } from "@/utils/stringify-email"; import { createScopedLogger } from "@/utils/logger"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM, RuleWithActions } from "@/utils/types"; import type { ActionType } from "@prisma/client"; @@ -36,12 +36,12 @@ const logger = createScopedLogger("AI Choose Args"); export async function aiGenerateArgs({ email, - user, + emailAccount, selectedRule, parameters, }: { email: EmailForLLM; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; selectedRule: RuleWithActions; parameters: { actionId: string; @@ -52,7 +52,7 @@ export async function aiGenerateArgs({ }[]; }) { const loggerOptions = { - email: user.email, + email: emailAccount.email, ruleId: selectedRule.id, ruleName: selectedRule.name, }; @@ -64,7 +64,7 @@ export async function aiGenerateArgs({ return; } - const system = getSystemPrompt({ user }); + const system = getSystemPrompt({ emailAccount }); const prompt = getPrompt({ email, selectedRule }); logger.info("Calling chat completion tools", loggerOptions); @@ -74,7 +74,7 @@ export async function aiGenerateArgs({ const aiResponse = await withRetry( () => chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, prompt, system, tools: { @@ -91,7 +91,7 @@ export async function aiGenerateArgs({ }, }, label: "Args for rule", - userEmail: user.email, + userEmail: emailAccount.email, }), { retryIf: (error: unknown) => InvalidToolArgumentsError.isInstance(error), @@ -111,7 +111,9 @@ export async function aiGenerateArgs({ return toolCallArgs; } -function getSystemPrompt({ user }: { user: UserEmailWithAI }) { +function getSystemPrompt({ + emailAccount, +}: { emailAccount: EmailAccountWithAI }) { return `You are an AI assistant that helps people manage their emails. @@ -125,7 +127,7 @@ function getSystemPrompt({ user }: { user: UserEmailWithAI }) { - Use proper capitalization and punctuation (start sentences with capital letters) - Ensure the generated text flows naturally with surrounding template content -${user.about ? `\n${user.about}` : ""}`; +${emailAccount.about ? `\n${emailAccount.about}` : ""}`; } function getPrompt({ diff --git a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts index 8d93b6160..07e811202 100644 --- a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts +++ b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { chatCompletionObject } from "@/utils/llms"; import { stringifyEmail } from "@/utils/stringify-email"; import type { EmailForLLM } from "@/utils/types"; @@ -12,12 +12,12 @@ const braintrust = new Braintrust("choose-rule-2"); type GetAiResponseOptions = { email: EmailForLLM; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; rules: { name: string; instructions: string }[]; }; async function getAiResponse(options: GetAiResponseOptions) { - const { email, user, rules } = options; + const { email, emailAccount, rules } = options; const emailSection = stringifyEmail(email, 500); @@ -51,13 +51,13 @@ ${rules ${ - user.about + emailAccount.about ? ` -${user.about} -${user.email} +${emailAccount.about} +${emailAccount.email} ` : ` -${user.email} +${emailAccount.email} ` } @@ -77,7 +77,7 @@ ${emailSection} logger.trace("Input", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, messages: [ { role: "system", @@ -101,7 +101,7 @@ ${emailSection} ruleName: z.string(), noMatchFound: z.boolean().optional(), }), - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Choose rule", }); @@ -115,9 +115,9 @@ ${emailSection} name: rule.name, instructions: rule.instructions, })), - hasAbout: !!user.about, - userAbout: user.about, - userEmail: user.email, + hasAbout: !!emailAccount.about, + userAbout: emailAccount.about, + userEmail: emailAccount.email, }, expected: aiResponse.object.ruleName, }); @@ -127,15 +127,21 @@ ${emailSection} export async function aiChooseRule< T extends { name: string; instructions: string }, ->(options: { email: EmailForLLM; rules: T[]; user: UserEmailWithAI }) { - const { email, rules, user } = options; - +>({ + email, + rules, + emailAccount, +}: { + email: EmailForLLM; + rules: T[]; + emailAccount: EmailAccountWithAI; +}) { if (!rules.length) return { reason: "No rules" }; const aiResponse = await getAiResponse({ email, rules, - user, + emailAccount, }); if (aiResponse.noMatchFound) diff --git a/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts b/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts index 5fa8351a9..389564353 100644 --- a/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts +++ b/apps/web/utils/ai/choose-rule/ai-detect-recurring-pattern.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { chatCompletionObject } from "@/utils/llms"; import type { EmailForLLM } from "@/utils/types"; import { stringifyEmail } from "@/utils/stringify-email"; @@ -17,11 +17,11 @@ export type DetectPatternResult = z.infer; export async function aiDetectRecurringPattern({ emails, - user, + emailAccount, rules, }: { emails: EmailForLLM[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; rules: { name: string; instructions: string; @@ -68,9 +68,9 @@ ${rules ${ - user.about - ? `\n${user.about}\n${user.email}\n` - : `\n${user.email}\n` + emailAccount.about + ? `\n${emailAccount.about}\n${emailAccount.email}\n` + : `\n${emailAccount.email}\n` } @@ -99,11 +99,11 @@ ${stringifyEmail(email, 500)} try { const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Detect recurring pattern", }); diff --git a/apps/web/utils/ai/choose-rule/choose-args.ts b/apps/web/utils/ai/choose-rule/choose-args.ts index c4c64dff4..5a9030cf5 100644 --- a/apps/web/utils/ai/choose-rule/choose-args.ts +++ b/apps/web/utils/ai/choose-rule/choose-args.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { gmail_v1 } from "@googleapis/gmail"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { ActionType, type Action } from "@prisma/client"; import { type RuleWithActions, @@ -21,12 +21,12 @@ type ActionArgResponse = { export async function getActionItemsWithAiArgs({ message, - user, + emailAccount, selectedRule, gmail, }: { message: ParsedMessage; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; selectedRule: RuleWithActions; gmail: gmail_v1.Gmail; }): Promise { @@ -41,7 +41,11 @@ export async function getActionItemsWithAiArgs({ let draft: string | null = null; if (draftEmailActions.length) { - draft = await fetchMessagesAndGenerateDraft(user, message.threadId, gmail); + draft = await fetchMessagesAndGenerateDraft( + emailAccount, + message.threadId, + gmail, + ); } const parameters = extractActionsNeedingAiGeneration(selectedRule.actions); @@ -50,7 +54,7 @@ export async function getActionItemsWithAiArgs({ const result = await aiGenerateArgs({ email, - user, + emailAccount, selectedRule, parameters, }); diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 4970d5d2d..ab386e71d 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -19,7 +19,7 @@ import prisma from "@/utils/prisma"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import { isReplyInThread } from "@/utils/thread"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { MatchReason, @@ -206,15 +206,20 @@ function getMatchReason(matchReasons?: MatchReason[]): string | undefined { export async function findMatchingRule({ rules, message, - user, + emailAccount, gmail, }: { rules: RuleWithActionsAndCategories[]; message: ParsedMessage; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; gmail: gmail_v1.Gmail; }) { - const result = await findMatchingRuleWithReasons(rules, message, user, gmail); + const result = await findMatchingRuleWithReasons( + rules, + message, + emailAccount, + gmail, + ); return { ...result, reason: result.reason || getMatchReason(result.matchReasons || []), @@ -224,7 +229,7 @@ export async function findMatchingRule({ async function findMatchingRuleWithReasons( rules: RuleWithActionsAndCategories[], message: ParsedMessage, - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, gmail: gmail_v1.Gmail, ): Promise<{ rule?: RuleWithActionsAndCategories; @@ -246,7 +251,7 @@ async function findMatchingRuleWithReasons( const result = await aiChooseRule({ email: getEmailForLLM(message), rules: potentialMatches, - user, + emailAccount, }); return result; diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 652b4fec4..4a280e1ae 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -4,7 +4,7 @@ import type { ParsedMessage, RuleWithActionsAndCategories, } from "@/utils/types"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { ExecutedRuleStatus, Prisma, @@ -36,22 +36,27 @@ export async function runRules({ gmail, message, rules, - user, + emailAccount, isTest, }: { gmail: gmail_v1.Gmail; message: ParsedMessage; rules: RuleWithActionsAndCategories[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; isTest: boolean; }): Promise { - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); analyzeSenderPatternIfAiMatch({ isTest, result, message, - email: user.email, + email: emailAccount.email, }); logger.trace("Matching rule", { result }); @@ -60,7 +65,7 @@ export async function runRules({ return await executeMatchedRule( result.rule, message, - user, + emailAccount, gmail, result.reason, result.matchReasons, @@ -68,7 +73,7 @@ export async function runRules({ ); } else { await saveSkippedExecutedRule({ - emailAccountId: user.email, + emailAccountId: emailAccount.email, threadId: message.threadId, messageId: message.id, reason: result.reason, @@ -80,7 +85,7 @@ export async function runRules({ async function executeMatchedRule( rule: RuleWithActionsAndCategories, message: ParsedMessage, - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, gmail: gmail_v1.Gmail, reason: string | undefined, matchReasons: MatchReason[] | undefined, @@ -89,7 +94,7 @@ async function executeMatchedRule( // get action items with args const actionItems = await getActionItemsWithAiArgs({ message, - user, + emailAccount, selectedRule: rule, gmail, }); @@ -99,7 +104,7 @@ async function executeMatchedRule( ? undefined : await saveExecutedRule( { - emailAccountId: user.email, + emailAccountId: emailAccount.id, threadId: message.threadId, messageId: message.id, }, @@ -115,7 +120,7 @@ async function executeMatchedRule( if (shouldExecute) { await executeAct({ gmail, - userEmail: user.email, + userEmail: emailAccount.email, executedRule, message, }); diff --git a/apps/web/utils/ai/clean/ai-clean-select-labels.ts b/apps/web/utils/ai/clean/ai-clean-select-labels.ts index 57eab7e8c..d5c792974 100644 --- a/apps/web/utils/ai/clean/ai-clean-select-labels.ts +++ b/apps/web/utils/ai/clean/ai-clean-select-labels.ts @@ -1,16 +1,16 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("ai/clean/select-labels"); const schema = z.object({ labels: z.array(z.string()).optional() }); export async function aiCleanSelectLabels({ - user, + emailAccount, instructions, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; instructions: string; }) { const system = `You are an AI assistant helping users organize their emails efficiently. @@ -31,11 +31,11 @@ ${instructions} logger.trace("Input", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Clean - Select Labels", }); diff --git a/apps/web/utils/ai/clean/ai-clean.ts b/apps/web/utils/ai/clean/ai-clean.ts index f3c3efe74..d8ceec48a 100644 --- a/apps/web/utils/ai/clean/ai-clean.ts +++ b/apps/web/utils/ai/clean/ai-clean.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { EmailForLLM } from "@/utils/types"; import { stringifyEmailSimple } from "@/utils/stringify-email"; @@ -21,13 +21,13 @@ const schema = z.object({ const braintrust = new Braintrust("cleaner-1"); export async function aiClean({ - user, + emailAccount, messageId, messages, instructions, skips, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; messageId: string; messages: EmailForLLM[]; instructions?: string; @@ -93,11 +93,11 @@ The current date is ${currentDate}. logger.trace("Input", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Clean", }); diff --git a/apps/web/utils/ai/example-matches/find-example-matches.ts b/apps/web/utils/ai/example-matches/find-example-matches.ts index 8d6fdbd2e..90e0fe91e 100644 --- a/apps/web/utils/ai/example-matches/find-example-matches.ts +++ b/apps/web/utils/ai/example-matches/find-example-matches.ts @@ -1,8 +1,7 @@ import { z } from "zod"; import type { gmail_v1 } from "@googleapis/gmail"; import { chatCompletionTools } from "@/utils/llms"; -import type { User } from "@prisma/client"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { queryBatchMessages } from "@/utils/gmail/message"; const FIND_EXAMPLE_MATCHES = "findExampleMatches"; @@ -27,7 +26,7 @@ export const findExampleMatchesSchema = z.object({ }); export async function aiFindExampleMatches( - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, gmail: gmail_v1.Gmail, rulesPrompt: string, ) { @@ -94,7 +93,7 @@ Remember, precision is crucial - only include matches you are absolutely sure ab }); const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, system, prompt, maxSteps: 10, @@ -105,7 +104,7 @@ Remember, precision is crucial - only include matches you are absolutely sure ab parameters: findExampleMatchesSchema, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Find example matches", }); diff --git a/apps/web/utils/ai/group/create-group.ts b/apps/web/utils/ai/group/create-group.ts index 2a26f2f8b..0385dc092 100644 --- a/apps/web/utils/ai/group/create-group.ts +++ b/apps/web/utils/ai/group/create-group.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import type { gmail_v1 } from "@googleapis/gmail"; import { chatCompletionTools } from "@/utils/llms"; -import type { Group, User } from "@prisma/client"; +import type { Group } from "@prisma/client"; import { queryBatchMessages } from "@/utils/gmail/message"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; // no longer in use. delete? @@ -54,7 +54,7 @@ const listEmailsTool = (gmail: gmail_v1.Gmail) => ({ }); export async function aiGenerateGroupItems( - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, gmail: gmail_v1.Gmail, group: Pick, ): Promise> { @@ -89,7 +89,7 @@ Key guidelines: logger.trace("aiGenerateGroupItems", { system, prompt }); const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, system, prompt, maxSteps: 10, @@ -100,7 +100,7 @@ Key guidelines: parameters: generateGroupItemsSchema, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Create group", }); @@ -123,11 +123,11 @@ Key guidelines: { senders: [], subjects: [] }, ); - return await verifyGroupItems(user, gmail, group, combinedArgs); + return await verifyGroupItems(emailAccount, gmail, group, combinedArgs); } async function verifyGroupItems( - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, gmail: gmail_v1.Gmail, group: Pick, initialItems: z.infer, @@ -160,7 +160,7 @@ Guidelines: 6. When using listEmails, make separate calls for each sender and subject. Do not combine them in a single query.`; const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, system, prompt, maxSteps: 10, @@ -171,7 +171,7 @@ Guidelines: parameters: verifyGroupItemsSchema, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Verify group criteria", }); diff --git a/apps/web/utils/ai/knowledge/extract-from-email-history.ts b/apps/web/utils/ai/knowledge/extract-from-email-history.ts index 6582253ed..e41246c7a 100644 --- a/apps/web/utils/ai/knowledge/extract-from-email-history.ts +++ b/apps/web/utils/ai/knowledge/extract-from-email-history.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createScopedLogger } from "@/utils/logger"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM } from "@/utils/types"; import { stringifyEmail } from "@/utils/stringify-email"; import { getTodayForLLM } from "@/utils/llms/helpers"; @@ -26,11 +26,11 @@ Provide a concise summary (max 500 characters) that captures the most important const getUserPrompt = ({ currentThreadMessages, historicalMessages, - user, + emailAccount, }: { currentThreadMessages: EmailForLLM[]; historicalMessages: EmailForLLM[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; }) => { return ` ${currentThreadMessages.map((m) => stringifyEmail(m, 10000)).join("\n---\n")} @@ -45,13 +45,13 @@ ${historicalMessages.map((m) => stringifyEmail(m, 10000)).join("\n---\n")} } ${ - user.about + emailAccount.about ? ` -${user.about} -${user.email} +${emailAccount.about} +${emailAccount.email} ` : ` -${user.email} +${emailAccount.email} ` } @@ -73,11 +73,11 @@ const extractionSchema = z.object({ export async function aiExtractFromEmailHistory({ currentThreadMessages, historicalMessages, - user, + emailAccount, }: { currentThreadMessages: EmailForLLM[]; historicalMessages: EmailForLLM[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; }): Promise { try { logger.info("Extracting information from email history", { @@ -91,7 +91,7 @@ export async function aiExtractFromEmailHistory({ const prompt = getUserPrompt({ currentThreadMessages, historicalMessages, - user, + emailAccount, }); logger.trace("Input", { system, prompt }); @@ -101,8 +101,8 @@ export async function aiExtractFromEmailHistory({ prompt, schema: extractionSchema, usageLabel: "Email history extraction", - userAi: user, - userEmail: user.email, + userAi: emailAccount.user, + userEmail: emailAccount.email, useEconomyModel: true, }); diff --git a/apps/web/utils/ai/knowledge/extract.ts b/apps/web/utils/ai/knowledge/extract.ts index 03b72be93..726020374 100644 --- a/apps/web/utils/ai/knowledge/extract.ts +++ b/apps/web/utils/ai/knowledge/extract.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { createScopedLogger } from "@/utils/logger"; import type { Knowledge } from "@prisma/client"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; const logger = createScopedLogger("ai/knowledge/extract"); @@ -39,11 +39,11 @@ The information you extract will be passed to another agent that will draft the const getUserPrompt = ({ knowledgeBase, emailContent, - user, + emailAccount, }: { knowledgeBase: Knowledge[]; emailContent: string; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; }) => { const knowledgeBaseText = knowledgeBase .map((k) => `Title: ${k.title}\nContent: ${k.content}`) @@ -58,13 +58,13 @@ ${knowledgeBaseText} ${ - user.about + emailAccount.about ? ` -${user.about} -${user.email} +${emailAccount.about} +${emailAccount.email} ` : ` -${user.email} +${emailAccount.email} ` } @@ -84,17 +84,17 @@ export type ExtractedKnowledge = z.infer; export async function aiExtractRelevantKnowledge({ knowledgeBase, emailContent, - user, + emailAccount, }: { knowledgeBase: Knowledge[]; emailContent: string; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; }): Promise { try { if (!knowledgeBase.length) return null; const system = SYSTEM_PROMPT; - const prompt = getUserPrompt({ knowledgeBase, emailContent, user }); + const prompt = getUserPrompt({ knowledgeBase, emailContent, emailAccount }); logger.trace("Input", { system, prompt: prompt.slice(0, 500) }); @@ -103,8 +103,8 @@ export async function aiExtractRelevantKnowledge({ prompt, schema: extractionSchema, usageLabel: "Knowledge extraction", - userAi: user, - userEmail: user.email, + userAi: emailAccount.user, + userEmail: emailAccount.email, useEconomyModel: true, }); diff --git a/apps/web/utils/ai/knowledge/writing-style.ts b/apps/web/utils/ai/knowledge/writing-style.ts index 77f982b77..723a7100a 100644 --- a/apps/web/utils/ai/knowledge/writing-style.ts +++ b/apps/web/utils/ai/knowledge/writing-style.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createScopedLogger } from "@/utils/logger"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM } from "@/utils/types"; import { truncate } from "@/utils/string"; import { removeExcessiveWhitespace } from "@/utils/string"; @@ -18,9 +18,9 @@ export const schema = z.object({ export async function aiAnalyzeWritingStyle(options: { emails: EmailForLLM[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; }) { - const { emails, user } = options; + const { emails, emailAccount } = options; if (!emails.length) { logger.warn("No emails provided for writing style analysis"); @@ -70,20 +70,20 @@ ${emails ${ - user.about + emailAccount.about ? `Some additional information about the user: -${user.about}` +${emailAccount.about}` : "" }`; logger.trace("Input", { system, prompt }); const result = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Writing Style Analysis", }); diff --git a/apps/web/utils/ai/reply/check-if-needs-reply.ts b/apps/web/utils/ai/reply/check-if-needs-reply.ts index 9f65a78a9..58dcb024e 100644 --- a/apps/web/utils/ai/reply/check-if-needs-reply.ts +++ b/apps/web/utils/ai/reply/check-if-needs-reply.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { EmailForLLM } from "@/utils/types"; import { @@ -23,11 +23,11 @@ const schema = z.object({ export type AICheckResult = z.infer; export async function aiCheckIfNeedsReply({ - user, + emailAccount, messageToSend, threadContextMessages, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; messageToSend: EmailForLLM; threadContextMessages: EmailForLLM[]; }): Promise { @@ -40,7 +40,7 @@ export async function aiCheckIfNeedsReply({ const system = "You are an AI assistant that checks if a reply is needed."; const prompt = - `${user.about ? `${user.about}` : ""} + `${emailAccount.about ? `${emailAccount.about}` : ""} We are sending the following message: @@ -66,11 +66,11 @@ Decide if the message we are sending needs a reply. logger.trace("Input", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Check if needs reply", }); diff --git a/apps/web/utils/ai/reply/draft-with-knowledge.ts b/apps/web/utils/ai/reply/draft-with-knowledge.ts index 6d61f3ae9..60b60fcc6 100644 --- a/apps/web/utils/ai/reply/draft-with-knowledge.ts +++ b/apps/web/utils/ai/reply/draft-with-knowledge.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createScopedLogger } from "@/utils/logger"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM } from "@/utils/types"; import { stringifyEmail } from "@/utils/stringify-email"; import { getTodayForLLM } from "@/utils/llms/helpers"; @@ -26,22 +26,22 @@ Do not invent information. For example, DO NOT offer to meet someone at a specif const getUserPrompt = ({ messages, - user, + emailAccount, knowledgeBaseContent, emailHistorySummary, writingStyle, }: { messages: (EmailForLLM & { to: string })[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; knowledgeBaseContent: string | null; emailHistorySummary: string | null; writingStyle: string | null; }) => { - const userAbout = user.about + const userAbout = emailAccount.about ? `Context about the user: -${user.about} +${emailAccount.about} ` : ""; @@ -89,7 +89,7 @@ ${stringifyEmail(msg, 3000)} Please write a reply to the email. ${getTodayForLLM()} -IMPORTANT: You are writing an email as ${user.email}. Write the reply from their perspective.`; +IMPORTANT: You are writing an email as ${emailAccount.email}. Write the reply from their perspective.`; }; const draftSchema = z.object({ @@ -102,13 +102,13 @@ const draftSchema = z.object({ export async function aiDraftWithKnowledge({ messages, - user, + emailAccount, knowledgeBaseContent, emailHistorySummary, writingStyle, }: { messages: (EmailForLLM & { to: string })[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; knowledgeBaseContent: string | null; emailHistorySummary: string | null; writingStyle: string | null; @@ -122,7 +122,7 @@ export async function aiDraftWithKnowledge({ const prompt = getUserPrompt({ messages, - user, + emailAccount, knowledgeBaseContent, emailHistorySummary, writingStyle, @@ -135,8 +135,8 @@ export async function aiDraftWithKnowledge({ prompt, schema: draftSchema, usageLabel: "Email draft with knowledge", - userAi: user, - userEmail: user.email, + userAi: emailAccount.user, + userEmail: emailAccount.email, }); logger.trace("Output", result.object); diff --git a/apps/web/utils/ai/reply/generate-nudge.ts b/apps/web/utils/ai/reply/generate-nudge.ts index abbf8b7ac..e6f7e5a67 100644 --- a/apps/web/utils/ai/reply/generate-nudge.ts +++ b/apps/web/utils/ai/reply/generate-nudge.ts @@ -1,5 +1,5 @@ import { chatCompletion } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { stringifyEmail } from "@/utils/stringify-email"; import { createScopedLogger } from "@/utils/logger"; import type { EmailForLLM } from "@/utils/types"; @@ -9,10 +9,10 @@ const logger = createScopedLogger("generate-nudge"); export async function aiGenerateNudge({ messages, - user, + emailAccount, }: { messages: EmailForLLM[]; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; onFinish?: (completion: string) => Promise; }) { const system = `You are an expert at writing follow-up emails that get responses. @@ -40,10 +40,10 @@ IMPORTANT: The person you're writing an email for is: ${messages.at(-1)?.from}.` logger.trace("Input", { system, prompt }); const response = await chatCompletion({ - userAi: user, + userAi: emailAccount.user, system, prompt, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Reply", }); diff --git a/apps/web/utils/ai/rule/create-rule.ts b/apps/web/utils/ai/rule/create-rule.ts index f792b8ae8..04d858543 100644 --- a/apps/web/utils/ai/rule/create-rule.ts +++ b/apps/web/utils/ai/rule/create-rule.ts @@ -1,18 +1,18 @@ import type { z } from "zod"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { chatCompletionTools } from "@/utils/llms"; import { createRuleSchema } from "@/utils/ai/rule/create-rule-schema"; export async function aiCreateRule( instructions: string, - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, ) { const system = "You are an AI assistant that helps people manage their emails."; const prompt = `Generate a rule for these instructions:\n${instructions}`; const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, prompt, system, tools: { @@ -21,7 +21,7 @@ export async function aiCreateRule( parameters: createRuleSchema, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Categorize rule", }); diff --git a/apps/web/utils/ai/rule/diff-rules.ts b/apps/web/utils/ai/rule/diff-rules.ts index 896be6a9a..61bd21f3b 100644 --- a/apps/web/utils/ai/rule/diff-rules.ts +++ b/apps/web/utils/ai/rule/diff-rules.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createPatch } from "diff"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; const parameters = z.object({ addedRules: z.array(z.string()).describe("The added rules"), @@ -17,11 +17,11 @@ const parameters = z.object({ }); export async function aiDiffRules({ - user, + emailAccount, oldPromptFile, newPromptFile, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; oldPromptFile: string; newPromptFile: string; }) { @@ -55,7 +55,7 @@ If a rule is edited, it is an edit and not a removal! Be extra careful to not ma `; const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, prompt, system, tools: { @@ -65,7 +65,7 @@ If a rule is edited, it is an edit and not a removal! Be extra careful to not ma parameters, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Diff rules", }); diff --git a/apps/web/utils/ai/rule/find-existing-rules.ts b/apps/web/utils/ai/rule/find-existing-rules.ts index 9238db77b..fe5a365ae 100644 --- a/apps/web/utils/ai/rule/find-existing-rules.ts +++ b/apps/web/utils/ai/rule/find-existing-rules.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { Action, Rule } from "@prisma/client"; const parameters = z.object({ @@ -17,12 +17,12 @@ const parameters = z.object({ }); export async function aiFindExistingRules({ - user, + emailAccount, promptRulesToEdit, promptRulesToRemove, databaseRules, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; promptRulesToEdit: { oldRule: string; newRule: string }[]; promptRulesToRemove: string[]; databaseRules: (Rule & { actions: Action[] })[]; @@ -45,7 +45,7 @@ ${JSON.stringify(databaseRules, null, 2)} Please return the existing rules that match the prompt rules.`; const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, prompt, system, tools: { @@ -54,7 +54,7 @@ Please return the existing rules that match the prompt rules.`; parameters, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Find existing rules", }); diff --git a/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts b/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts index 0b7ce8cdf..361a63fd0 100644 --- a/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts +++ b/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { RuleWithRelations } from "./create-prompt-from-rule"; import { createPromptFromRule } from "./create-prompt-from-rule"; @@ -14,11 +14,11 @@ const parameters = z.object({ }); export async function generatePromptOnDeleteRule({ - user, + emailAccount, existingPrompt, deletedRule, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; existingPrompt: string; deletedRule: RuleWithRelations; }): Promise { @@ -53,11 +53,11 @@ ${deletedRulePrompt} logger.trace("Input", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, prompt, system, schema: parameters, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Update prompt on delete rule", }); diff --git a/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts b/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts index 89ed9c220..1dee23ec7 100644 --- a/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts +++ b/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { RuleWithRelations } from "./create-prompt-from-rule"; import { createPromptFromRule } from "./create-prompt-from-rule"; @@ -14,12 +14,12 @@ const parameters = z.object({ }); export async function generatePromptOnUpdateRule({ - user, + emailAccount, existingPrompt, currentRule, updatedRule, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; existingPrompt: string; currentRule: RuleWithRelations; updatedRule: RuleWithRelations; @@ -58,11 +58,11 @@ ${updatedRulePrompt} logger.trace("Input", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, prompt, system, schema: parameters, - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Update prompt on update rule", }); diff --git a/apps/web/utils/ai/rule/generate-rules-prompt.ts b/apps/web/utils/ai/rule/generate-rules-prompt.ts index 631c8dc36..7ef0ac507 100644 --- a/apps/web/utils/ai/rule/generate-rules-prompt.ts +++ b/apps/web/utils/ai/rule/generate-rules-prompt.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("ai-generate-rules-prompt"); @@ -28,12 +28,12 @@ const parametersSnippets = z.object({ }); export async function aiGenerateRulesPrompt({ - user, + emailAccount, lastSentEmails, snippets, userLabels, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; lastSentEmails: string[]; userLabels: string[]; snippets: string[]; @@ -55,9 +55,9 @@ export async function aiGenerateRulesPrompt({ const prompt = `Analyze the user's email behavior and suggest general rules for managing their inbox effectively. Here's the context: -${user.email} +${emailAccount.email} -${user.about ? `\n\n${user.about}\n\n` : ""} +${emailAccount.about ? `\n\n${emailAccount.about}\n\n` : ""} ${lastSentEmails .slice(0, lastSentEmailsCount) @@ -109,7 +109,7 @@ Your response should only include the list of general rules. Aim for 3-10 broadl logger.trace("generate-rules-prompt", { system, prompt }); const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, prompt, system, tools: { @@ -118,7 +118,7 @@ Your response should only include the list of general rules. Aim for 3-10 broadl parameters: hasSnippets ? parametersSnippets : parameters, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Generate rules prompt", }); diff --git a/apps/web/utils/ai/rule/prompt-to-rules.ts b/apps/web/utils/ai/rule/prompt-to-rules.ts index d85dbcc43..7f16a3ab6 100644 --- a/apps/web/utils/ai/rule/prompt-to-rules.ts +++ b/apps/web/utils/ai/rule/prompt-to-rules.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserAIFields, UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createRuleSchema, getCreateRuleSchemaWithCategories, @@ -17,12 +17,12 @@ const updateRuleSchema = createRuleSchema.extend({ }); export async function aiPromptToRules({ - user, + emailAccount, promptFile, isEditing, availableCategories, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; promptFile: string; isEditing: boolean; availableCategories?: string[]; @@ -71,7 +71,7 @@ ${promptFile} } const aiResponse = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, prompt, system, tools: { @@ -80,7 +80,7 @@ ${promptFile} parameters, }, }, - userEmail: user.email, + userEmail: emailAccount.email, label: "Prompt to rules", }); diff --git a/apps/web/utils/ai/rule/rule-fix.ts b/apps/web/utils/ai/rule/rule-fix.ts index 0d507ac1a..12eef03d0 100644 --- a/apps/web/utils/ai/rule/rule-fix.ts +++ b/apps/web/utils/ai/rule/rule-fix.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { stringifyEmail } from "@/utils/stringify-email"; import type { EmailForLLM } from "@/utils/types"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { Rule } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; @@ -14,13 +14,13 @@ export type RuleFixResponse = { }; export async function aiRuleFix({ - user, + emailAccount, actualRule, expectedRule, email, explanation, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; actualRule: Pick | null; expectedRule: Pick | null; email: EmailForLLM; @@ -40,7 +40,7 @@ export async function aiRuleFix({ 4. Make minimal changes to fix the issue while maintaining the original intent -${user.about ? `${user.about}` : ""} +${emailAccount.about ? `${emailAccount.about}` : ""} Example Outputs: @@ -66,11 +66,11 @@ Please provide the fixed rule.`; logger.trace("ai-rule-fix", { system, prompt }); const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, prompt, system, schema, - userEmail: user.email ?? "", + userEmail: emailAccount.email ?? "", usageLabel: "ai-rule-fix", }); diff --git a/apps/web/utils/ai/snippets/find-snippets.ts b/apps/web/utils/ai/snippets/find-snippets.ts index a6480ff18..76dc2d62d 100644 --- a/apps/web/utils/ai/snippets/find-snippets.ts +++ b/apps/web/utils/ai/snippets/find-snippets.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { stringifyEmail } from "@/utils/stringify-email"; import type { EmailForLLM } from "@/utils/types"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("AI Find Snippets"); @@ -19,10 +19,10 @@ const snippetsSchema = z.object({ export type SnippetsResponse = z.infer; export async function aiFindSnippets({ - user, + emailAccount, sentEmails, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; sentEmails: EmailForLLM[]; }) { const system = `You are an AI assistant that analyzes email content to find common snippets (canned responses) that the user frequently uses. @@ -63,11 +63,11 @@ ${sentEmails .join("\n")}`; const aiResponse = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, prompt, system, schema: snippetsSchema, - userEmail: user.email ?? "", + userEmail: emailAccount.email ?? "", usageLabel: "ai-find-snippets", }); diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index 4512a7412..e14341aab 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -8,7 +8,7 @@ import { aiCategorizeSender } from "@/utils/ai/categorize-sender/ai-categorize-s import { getThreadsFromSenderWithSubject } from "@/utils/gmail/thread"; import type { Category } from "@prisma/client"; import { getUserCategories } from "@/utils/category.server"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import { extractEmailAddress } from "@/utils/email"; import { SafeError } from "@/utils/error"; @@ -17,13 +17,14 @@ const logger = createScopedLogger("categorize/senders"); export async function categorizeSender( senderAddress: string, - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, gmail: gmail_v1.Gmail, accessToken: string, userCategories?: Pick[], ) { const categories = - userCategories || (await getUserCategories({ email: user.email })); + userCategories || + (await getUserCategories({ emailAccountId: emailAccount.id })); if (categories.length === 0) return { categoryId: undefined }; const previousEmails = await getThreadsFromSenderWithSubject( @@ -34,7 +35,7 @@ export async function categorizeSender( ); const aiResult = await aiCategorizeSender({ - user, + emailAccount, sender: senderAddress, previousEmails, categories, @@ -45,14 +46,14 @@ export async function categorizeSender( sender: senderAddress, categories, categoryName: aiResult.category, - userEmail: user.email, + userEmail: emailAccount.email, }); return { categoryId: newsletter.categoryId }; } logger.error("No AI result for sender", { - userEmail: user.email, + userEmail: emailAccount.email, senderAddress, }); @@ -141,18 +142,20 @@ function preCategorizeSendersWithStaticRules( }); } -export async function getCategories({ email }: { email: string }) { - const categories = await getUserCategories({ email }); +export async function getCategories({ + emailAccountId, +}: { emailAccountId: string }) { + const categories = await getUserCategories({ emailAccountId }); if (categories.length === 0) throw new SafeError("No categories found"); return { categories }; } export async function categorizeWithAi({ - user, + emailAccount, sendersWithEmails, categories, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; sendersWithEmails: Map; categories: Pick[]; }) { @@ -165,12 +168,12 @@ export async function categorizeWithAi({ .map((sender) => sender.sender); logger.info("Found senders to categorize with AI", { - userEmail: user.email, + userEmail: emailAccount.email, count: sendersToCategorizeWithAi.length, }); const aiResults = await aiCategorizeSenders({ - user, + emailAccount, senders: sendersToCategorizeWithAi.map((sender) => ({ emailAddress: sender, emails: sendersWithEmails.get(sender) || [], diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index cd215475c..9a91850d8 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { gmail_v1 } from "@googleapis/gmail"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { getOrCreateInboxZeroLabel, GmailLabel } from "@/utils/gmail/label"; import { labelMessage } from "@/utils/gmail/label"; import { @@ -22,11 +22,11 @@ type ColdEmailBlockerReason = "hasPreviousEmail" | "ai" | "ai-already-labeled"; export async function isColdEmail({ email, - user, + emailAccount, gmail, }: { email: EmailForLLM & { threadId?: string }; - user: Pick & UserEmailWithAI; + emailAccount: Pick & EmailAccountWithAI; gmail: gmail_v1.Gmail; }): Promise<{ isColdEmail: boolean; @@ -34,7 +34,7 @@ export async function isColdEmail({ aiReason?: string | null; }> { const loggerOptions = { - email: user.email, + email: emailAccount.email, threadId: email.threadId, messageId: email.id, }; @@ -44,7 +44,7 @@ export async function isColdEmail({ // Check if we marked it as a cold email already const isColdEmailer = await isKnownColdEmailSender({ from: email.from, - emailAccountId: user.email, + emailAccountId: emailAccount.email, }); if (isColdEmailer) { @@ -70,7 +70,7 @@ export async function isColdEmail({ } // otherwise run through ai to see if it's a cold email - const res = await aiIsColdEmail(email, user); + const res = await aiIsColdEmail(email, emailAccount); logger.info("AI is cold email?", { ...loggerOptions, @@ -106,12 +106,12 @@ async function isKnownColdEmailSender({ async function aiIsColdEmail( email: EmailForLLM, - user: Pick & UserEmailWithAI, + emailAccount: Pick & EmailAccountWithAI, ) { const system = `You are an assistant that decides if an email is a cold email or not. -${user.coldEmailPrompt || DEFAULT_COLD_EMAIL_PROMPT} +${emailAccount.coldEmailPrompt || DEFAULT_COLD_EMAIL_PROMPT} @@ -136,14 +136,14 @@ ${stringifyEmail(email, 500)} logger.trace("AI is cold email prompt", { system, prompt }); const response = await chatCompletionObject({ - userAi: user, + userAi: emailAccount.user, system, prompt, schema: z.object({ coldEmail: z.boolean(), reason: z.string(), }), - userEmail: user.email, + userEmail: emailAccount.email, usageLabel: "Cold email check", }); @@ -155,8 +155,8 @@ ${stringifyEmail(email, 500)} export async function runColdEmailBlocker(options: { email: EmailForLLM & { threadId: string }; gmail: gmail_v1.Gmail; - user: Pick & - UserEmailWithAI; + emailAccount: Pick & + EmailAccountWithAI; }) { const response = await isColdEmail(options); if (response.isColdEmail) @@ -167,15 +167,15 @@ export async function runColdEmailBlocker(options: { export async function blockColdEmail(options: { gmail: gmail_v1.Gmail; email: { from: string; id: string; threadId: string }; - user: Pick & UserEmailWithAI; + emailAccount: Pick & EmailAccountWithAI; aiReason: string | null; }) { - const { gmail, email, user, aiReason } = options; + const { gmail, email, emailAccount, aiReason } = options; await prisma.coldEmail.upsert({ where: { emailAccountId_fromEmail: { - emailAccountId: user.email, + emailAccountId: emailAccount.id, fromEmail: email.from, }, }, @@ -183,7 +183,7 @@ export async function blockColdEmail(options: { create: { status: ColdEmailStatus.AI_LABELED_COLD, fromEmail: email.from, - emailAccountId: user.email, + emailAccountId: emailAccount.id, reason: aiReason, messageId: email.id, threadId: email.threadId, @@ -191,24 +191,27 @@ export async function blockColdEmail(options: { }); if ( - user.coldEmailBlocker === ColdEmailSetting.LABEL || - user.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || - user.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL + emailAccount.coldEmailBlocker === ColdEmailSetting.LABEL || + emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || + emailAccount.coldEmailBlocker === + ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL ) { - if (!user.email) throw new Error("User email is required"); + if (!emailAccount.email) throw new Error("User email is required"); const coldEmailLabel = await getOrCreateInboxZeroLabel({ gmail, key: "cold_email", }); if (!coldEmailLabel?.id) - logger.error("No gmail label id", { emailAccountId: user.email }); + logger.error("No gmail label id", { emailAccountId: emailAccount.id }); const shouldArchive = - user.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || - user.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; + emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || + emailAccount.coldEmailBlocker === + ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; const shouldMarkRead = - user.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; + emailAccount.coldEmailBlocker === + ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; const addLabelIds: string[] = []; if (coldEmailLabel?.id) addLabelIds.push(coldEmailLabel.id); diff --git a/apps/web/utils/hash.ts b/apps/web/utils/hash.ts deleted file mode 100644 index 329c842e4..000000000 --- a/apps/web/utils/hash.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createHash } from "node:crypto"; - -export const hash = (str: string) => - createHash("sha256").update(str).digest("hex").slice(0, 16); diff --git a/apps/web/utils/llms/types.ts b/apps/web/utils/llms/types.ts index 8b83da003..114dc8265 100644 --- a/apps/web/utils/llms/types.ts +++ b/apps/web/utils/llms/types.ts @@ -1,8 +1,24 @@ -import type { EmailAccount } from "@prisma/client"; +import type { User, EmailAccount, Prisma } from "@prisma/client"; -export type UserAIFields = Pick< - EmailAccount, - "aiProvider" | "aiModel" | "aiApiKey" ->; -export type UserEmailWithAI = Pick & - UserAIFields; +export type UserAIFields = Prisma.UserGetPayload<{ + select: { + aiProvider: true; + aiModel: true; + aiApiKey: true; + }; +}>; +export type EmailAccountWithAI = Prisma.EmailAccountGetPayload<{ + select: { + id: true; + userId: true; + email: true; + about: true; + user: { + select: { + aiProvider: true; + aiModel: true; + aiApiKey: true; + }; + }; + }; +}>; diff --git a/apps/web/utils/redis/account-validation.ts b/apps/web/utils/redis/account-validation.ts index f5948f346..1cd891c16 100644 --- a/apps/web/utils/redis/account-validation.ts +++ b/apps/web/utils/redis/account-validation.ts @@ -7,8 +7,14 @@ const EXPIRATION = 60 * 60; // 1 hour /** * Get the Redis key for account validation */ -function getValidationKey(userId: string, accountId: string): string { - return `account:${userId}:${accountId}`; +function getValidationKey({ + userId, + emailAccountId, +}: { + userId: string; + emailAccountId: string; +}): string { + return `account:${userId}:${emailAccountId}`; } /** @@ -17,13 +23,16 @@ function getValidationKey(userId: string, accountId: string): string { * @param accountId The account ID to validate * @returns email address of the account if it belongs to the user, otherwise null */ -export async function validateUserAccount( - userId: string, - accountId: string, -): Promise { - if (!userId || !accountId) return null; +export async function getEmailAccount({ + userId, + emailAccountId, +}: { + userId: string; + emailAccountId: string; +}): Promise { + if (!userId || !emailAccountId) return null; - const key = getValidationKey(userId, accountId); + const key = getValidationKey({ userId, emailAccountId }); // Check Redis cache first const cachedResult = await redis.get(key); @@ -34,7 +43,7 @@ export async function validateUserAccount( // Not in cache, check database const emailAccount = await prisma.emailAccount.findUnique({ - where: { accountId, userId }, + where: { id: emailAccountId, userId }, select: { email: true }, }); @@ -48,10 +57,13 @@ export async function validateUserAccount( * Invalidate the cached validation result for a user's account * Useful when account ownership changes */ -export async function invalidateAccountValidation( - userId: string, - accountId: string, -): Promise { - const key = getValidationKey(userId, accountId); +export async function invalidateAccountValidation({ + userId, + emailAccountId, +}: { + userId: string; + emailAccountId: string; +}): Promise { + const key = getValidationKey({ userId, emailAccountId }); await redis.del(key); } diff --git a/apps/web/utils/redis/categorization-progress.ts b/apps/web/utils/redis/categorization-progress.ts index f728bfadf..784b1d4d7 100644 --- a/apps/web/utils/redis/categorization-progress.ts +++ b/apps/web/utils/redis/categorization-progress.ts @@ -7,30 +7,30 @@ const categorizationProgressSchema = z.object({ }); type RedisCategorizationProgress = z.infer; -function getKey({ email }: { email: string }) { - return `categorization-progress:${email}`; +function getKey({ emailAccountId }: { emailAccountId: string }) { + return `categorization-progress:${emailAccountId}`; } export async function getCategorizationProgress({ - email, + emailAccountId, }: { - email: string; + emailAccountId: string; }) { - const key = getKey({ email }); + const key = getKey({ emailAccountId }); const progress = await redis.get(key); if (!progress) return null; return progress; } export async function saveCategorizationTotalItems({ - email, + emailAccountId, totalItems, }: { - email: string; + emailAccountId: string; totalItems: number; }) { - const key = getKey({ email }); - const existingProgress = await getCategorizationProgress({ email }); + const key = getKey({ emailAccountId }); + const existingProgress = await getCategorizationProgress({ emailAccountId }); await redis.set( key, { @@ -42,16 +42,16 @@ export async function saveCategorizationTotalItems({ } export async function saveCategorizationProgress({ - email, + emailAccountId, incrementCompleted, }: { - email: string; + emailAccountId: string; incrementCompleted: number; }) { - const existingProgress = await getCategorizationProgress({ email }); + const existingProgress = await getCategorizationProgress({ emailAccountId }); if (!existingProgress) return null; - const key = getKey({ email }); + const key = getKey({ emailAccountId }); const updatedProgress: RedisCategorizationProgress = { ...existingProgress, completedItems: (existingProgress.completedItems || 0) + incrementCompleted, diff --git a/apps/web/utils/redis/category.ts b/apps/web/utils/redis/category.ts index 307095256..7708ffd73 100644 --- a/apps/web/utils/redis/category.ts +++ b/apps/web/utils/redis/category.ts @@ -6,44 +6,56 @@ const categorySchema = z.object({ }); export type RedisCategory = z.infer; -function getKey(email: string) { - return `categories:${email}`; +function getKey({ emailAccountId }: { emailAccountId: string }) { + return `categories:${emailAccountId}`; } -function getCategoryKey(threadId: string) { +function getCategoryKey({ threadId }: { threadId: string }) { return `category:${threadId}`; } -export async function getCategory(options: { - email: string; +export async function getCategory({ + emailAccountId, + threadId, +}: { + emailAccountId: string; threadId: string; }) { - const key = getKey(options.email); - const categoryKey = getCategoryKey(options.threadId); + const key = getKey({ emailAccountId }); + const categoryKey = getCategoryKey({ threadId }); const category = await redis.hget(key, categoryKey); if (!category) return null; return { ...category, id: categoryKey }; } -export async function saveCategory(options: { - email: string; +export async function saveCategory({ + emailAccountId, + threadId, + category, +}: { + emailAccountId: string; threadId: string; category: RedisCategory; }) { - const key = getKey(options.email); - const categoryKey = getCategoryKey(options.threadId); - return redis.hset(key, { [categoryKey]: options.category }); + const key = getKey({ emailAccountId }); + const categoryKey = getCategoryKey({ threadId }); + return redis.hset(key, { [categoryKey]: category }); } -export async function deleteCategory(options: { - email: string; +export async function deleteCategory({ + emailAccountId, + threadId, +}: { + emailAccountId: string; threadId: string; }) { - const key = getKey(options.email); - const categoryKey = getCategoryKey(options.threadId); + const key = getKey({ emailAccountId }); + const categoryKey = getCategoryKey({ threadId }); return redis.hdel(key, categoryKey); } -export async function deleteCategories(options: { email: string }) { - const key = getKey(options.email); +export async function deleteCategories({ + emailAccountId, +}: { emailAccountId: string }) { + const key = getKey({ emailAccountId }); return redis.del(key); } diff --git a/apps/web/utils/redis/clean.ts b/apps/web/utils/redis/clean.ts index cf0fdde8e..97048d0dd 100644 --- a/apps/web/utils/redis/clean.ts +++ b/apps/web/utils/redis/clean.ts @@ -4,11 +4,21 @@ import { isDefined } from "@/utils/types"; const EXPIRATION = 60 * 60 * 6; // 6 hours -const threadKey = (email: string, jobId: string, threadId: string) => - `thread:${email}:${jobId}:${threadId}`; +const threadKey = ({ + emailAccountId, + jobId, + threadId, +}: { + emailAccountId: string; + jobId: string; + threadId: string; +}) => `thread:${emailAccountId}:${jobId}:${threadId}`; -export async function saveThread( - email: string, +export async function saveThread({ + emailAccountId, + thread, +}: { + emailAccountId: string; thread: { threadId: string; jobId: string; @@ -18,48 +28,52 @@ export async function saveThread( date: Date; archive?: boolean; label?: string; - }, -): Promise { + }; +}): Promise { const cleanThread: CleanThread = { ...thread, - email, + emailAccountId, status: "processing", createdAt: new Date().toISOString(), }; - await publishThread({ email, thread: cleanThread }); + await publishThread({ emailAccountId, thread: cleanThread }); return cleanThread; } export async function updateThread({ - email, + emailAccountId, jobId, threadId, update, }: { - email: string; + emailAccountId: string; jobId: string; threadId: string; update: Partial; }) { - const thread = await getThread(email, jobId, threadId); + const thread = await getThread({ emailAccountId, jobId, threadId }); if (!thread) { console.warn("thread not found:", threadId); return; } const updatedThread = { ...thread, ...update }; - await publishThread({ email, thread: updatedThread }); + await publishThread({ emailAccountId, thread: updatedThread }); } export async function publishThread({ - email, + emailAccountId, thread, }: { - email: string; + emailAccountId: string; thread: CleanThread; }) { - const key = threadKey(email, thread.jobId, thread.threadId); + const key = threadKey({ + emailAccountId, + jobId: thread.jobId, + threadId: thread.threadId, + }); // Store the data with expiration await redis.set(key, thread, { ex: EXPIRATION }); @@ -67,11 +81,18 @@ export async function publishThread({ await redis.publish(key, JSON.stringify(thread)); } -async function getThread(email: string, jobId: string, threadId: string) { - const key = threadKey(email, jobId, threadId); +async function getThread({ + emailAccountId, + jobId, + threadId, +}: { + emailAccountId: string; + jobId: string; + threadId: string; +}) { + const key = threadKey({ emailAccountId, jobId, threadId }); return redis.get(key); } - export async function getThreadsByJobId({ emailAccountId, jobId, diff --git a/apps/web/utils/redis/clean.types.ts b/apps/web/utils/redis/clean.types.ts index 53fe9bc95..9e9c144d6 100644 --- a/apps/web/utils/redis/clean.types.ts +++ b/apps/web/utils/redis/clean.types.ts @@ -1,6 +1,6 @@ export type CleanThread = { + emailAccountId: string; threadId: string; - email: string; jobId: string; status: "processing" | "applying" | "completed"; createdAt: string; diff --git a/apps/web/utils/redis/reply-tracker-analyzing.ts b/apps/web/utils/redis/reply-tracker-analyzing.ts index 4f96c239f..8cc809b6e 100644 --- a/apps/web/utils/redis/reply-tracker-analyzing.ts +++ b/apps/web/utils/redis/reply-tracker-analyzing.ts @@ -1,22 +1,28 @@ import { redis } from "@/utils/redis"; -function getKey(email: string) { - return `reply-tracker:analyzing:${email}`; +function getKey({ emailAccountId }: { emailAccountId: string }) { + return `reply-tracker:analyzing:${emailAccountId}`; } -export async function startAnalyzingReplyTracker({ email }: { email: string }) { - const key = getKey(email); +export async function startAnalyzingReplyTracker({ + emailAccountId, +}: { emailAccountId: string }) { + const key = getKey({ emailAccountId }); // expire in 5 minutes await redis.set(key, "true", { ex: 5 * 60 }); } -export async function stopAnalyzingReplyTracker({ email }: { email: string }) { - const key = getKey(email); +export async function stopAnalyzingReplyTracker({ + emailAccountId, +}: { emailAccountId: string }) { + const key = getKey({ emailAccountId }); await redis.del(key); } -export async function isAnalyzingReplyTracker({ email }: { email: string }) { - const key = getKey(email); +export async function isAnalyzingReplyTracker({ + emailAccountId, +}: { emailAccountId: string }) { + const key = getKey({ emailAccountId }); const result = await redis.get(key); return result === "true"; } diff --git a/apps/web/utils/reply-tracker/check-previous-emails.ts b/apps/web/utils/reply-tracker/check-previous-emails.ts index 11d6e7936..038eeea71 100644 --- a/apps/web/utils/reply-tracker/check-previous-emails.ts +++ b/apps/web/utils/reply-tracker/check-previous-emails.ts @@ -4,7 +4,7 @@ import { createScopedLogger } from "@/utils/logger"; import { getThreadMessages, getThreads } from "@/utils/gmail/thread"; import { GmailLabel } from "@/utils/gmail/label"; import { handleOutboundReply } from "@/utils/reply-tracker/outbound"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { handleInboundReply } from "@/utils/reply-tracker/inbound"; import { getAssistantEmail } from "@/utils/assistant/is-assistant-email"; import prisma from "@/utils/prisma"; @@ -13,12 +13,10 @@ const logger = createScopedLogger("reply-tracker/check-previous-emails"); export async function processPreviousSentEmails( gmail: gmail_v1.Gmail, - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, maxResults = 100, ) { - if (!user.email) throw new Error("User email not found"); - - const assistantEmail = getAssistantEmail({ userEmail: user.email }); + const assistantEmail = getAssistantEmail({ userEmail: emailAccount.email }); // Get last sent messages const result = await getThreads( @@ -47,7 +45,7 @@ export async function processPreviousSentEmails( const isProcessed = await prisma.executedRule.findUnique({ where: { unique_emailAccount_thread_message: { - emailAccountId: user.email, + emailAccountId: emailAccount.id, threadId: latestMessage.threadId, messageId: latestMessage.id, }, @@ -56,7 +54,7 @@ export async function processPreviousSentEmails( }); const loggerOptions = { - email: user.email, + email: emailAccount.email, messageId: latestMessage.id, threadId: latestMessage.threadId, }; @@ -70,11 +68,11 @@ export async function processPreviousSentEmails( if (latestMessage.labelIds?.includes(GmailLabel.SENT)) { // outbound logger.info("Processing outbound reply", loggerOptions); - await handleOutboundReply(user, latestMessage, gmail); + await handleOutboundReply(emailAccount, latestMessage, gmail); } else { // inbound logger.info("Processing inbound reply", loggerOptions); - await handleInboundReply(user, latestMessage, gmail); + await handleInboundReply(emailAccount, latestMessage, gmail); } revalidatePath("/reply-zero"); diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index 001ef2a63..3b9e26a36 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -9,20 +9,28 @@ import { createScopedLogger } from "@/utils/logger"; import { RuleName } from "@/utils/rule/consts"; import { SafeError } from "@/utils/error"; -export async function enableReplyTracker({ email }: { email: string }) { - const logger = createScopedLogger("reply-tracker/enable").with({ email }); +export async function enableReplyTracker({ + emailAccountId, +}: { emailAccountId: string }) { + const logger = createScopedLogger("reply-tracker/enable").with({ + emailAccountId, + }); // Find existing reply required rule, make it track replies const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { userId: true, email: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, about: true, rulesPrompt: true, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, rules: { where: { systemType: SystemType.TO_REPLY, @@ -116,7 +124,7 @@ export async function enableReplyTracker({ email }: { email: string }) { // Add rule to prompt file await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { rulesPrompt: `${emailAccount.rulesPrompt || ""}\n\n* Label emails that require a reply as 'Reply Required'`.trim(), @@ -139,7 +147,7 @@ export async function enableReplyTracker({ email }: { email: string }) { await Promise.allSettled([ enableReplyTracking(updatedRule), enableDraftReplies(updatedRule), - enableOutboundReplyTracking({ email }), + enableOutboundReplyTracking({ emailAccountId }), ]); } @@ -172,9 +180,11 @@ export async function enableDraftReplies( }); } -async function enableOutboundReplyTracking({ email }: { email: string }) { +async function enableOutboundReplyTracking({ + emailAccountId, +}: { emailAccountId: string }) { await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { outboundReplyTracking: true }, }); } diff --git a/apps/web/utils/reply-tracker/generate-draft.ts b/apps/web/utils/reply-tracker/generate-draft.ts index 25bedb993..b1d4a2193 100644 --- a/apps/web/utils/reply-tracker/generate-draft.ts +++ b/apps/web/utils/reply-tracker/generate-draft.ts @@ -5,9 +5,9 @@ import { getEmailForLLM } from "@/utils/get-email-from-message"; import { draftEmail } from "@/utils/gmail/mail"; import { aiDraftWithKnowledge } from "@/utils/ai/reply/draft-with-knowledge"; import { getReply, saveReply } from "@/utils/redis/reply"; -import { getAiUser, getWritingStyle } from "@/utils/user/get"; +import { getEmailAccountWithAi, getWritingStyle } from "@/utils/user/get"; import { getThreadMessages } from "@/utils/gmail/thread"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import prisma from "@/utils/prisma"; import { aiExtractRelevantKnowledge } from "@/utils/ai/knowledge/extract"; @@ -38,12 +38,12 @@ export async function generateDraft({ logger.info("Generating draft"); - const user = await getAiUser({ email: userEmail }); - if (!user) throw new Error("User not found"); + const emailAccount = await getEmailAccountWithAi({ emailAccountId: userId }); + if (!emailAccount) throw new Error("User not found"); // 1. Draft with AI const result = await fetchMessagesAndGenerateDraft( - user, + emailAccount, message.threadId, gmail, ); @@ -64,7 +64,7 @@ export async function generateDraft({ * Fetches thread messages and generates draft content in one step */ export async function fetchMessagesAndGenerateDraft( - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, threadId: string, gmail: gmail_v1.Gmail, ): Promise { @@ -72,7 +72,7 @@ export async function fetchMessagesAndGenerateDraft( await fetchThreadAndConversationMessages(threadId, gmail); const result = await generateDraftContent( - user, + emailAccount, threadMessages, previousConversationMessages, ); @@ -110,7 +110,7 @@ async function fetchThreadAndConversationMessages( } async function generateDraftContent( - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, threadMessages: ParsedMessage[], previousConversationMessages: ParsedMessage[] | null, ) { @@ -119,7 +119,7 @@ async function generateDraftContent( if (!lastMessage) throw new Error("No message provided"); const reply = await getReply({ - email: user.email, + email: emailAccount.email, messageId: lastMessage.id, }); @@ -138,7 +138,7 @@ async function generateDraftContent( // 1. Get knowledge base entries const knowledgeBase = await prisma.knowledge.findMany({ - where: { emailAccountId: user.email }, + where: { emailAccountId: emailAccount.id }, orderBy: { updatedAt: "desc" }, }); @@ -151,7 +151,7 @@ async function generateDraftContent( const knowledgeResult = await aiExtractRelevantKnowledge({ knowledgeBase, emailContent: lastMessageContent, - user, + emailAccount, }); // 2b. Extract email history context @@ -174,16 +174,18 @@ async function generateDraftContent( ? await aiExtractFromEmailHistory({ currentThreadMessages: messages, historicalMessages: historicalMessagesForLLM, - user, + emailAccount, }) : null; - const writingStyle = await getWritingStyle(user.email); + const writingStyle = await getWritingStyle({ + emailAccountId: emailAccount.id, + }); // 3. Draft with extracted knowledge const text = await aiDraftWithKnowledge({ messages, - user, + emailAccount, knowledgeBaseContent: knowledgeResult?.relevantContent || null, emailHistorySummary, writingStyle, @@ -191,7 +193,7 @@ async function generateDraftContent( if (typeof text === "string") { await saveReply({ - email: user.email, + email: emailAccount.email, messageId: lastMessage.id, reply: text, }); diff --git a/apps/web/utils/reply-tracker/inbound.ts b/apps/web/utils/reply-tracker/inbound.ts index 556810ea9..dcb973010 100644 --- a/apps/web/utils/reply-tracker/inbound.ts +++ b/apps/web/utils/reply-tracker/inbound.ts @@ -4,7 +4,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { getAwaitingReplyLabel } from "@/utils/reply-tracker/label"; import { removeThreadLabel } from "@/utils/gmail/label"; import { createScopedLogger } from "@/utils/logger"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { ParsedMessage } from "@/utils/types"; import { internalDateToDate } from "@/utils/date"; import { getEmailForLLM } from "@/utils/get-email-from-message"; @@ -117,7 +117,7 @@ async function updateThreadTrackers({ // Currently this is used when enabling reply tracking. Otherwise we use regular AI rule processing to handle inbound replies export async function handleInboundReply( - user: UserEmailWithAI, + user: EmailAccountWithAI, message: ParsedMessage, gmail: gmail_v1.Gmail, ) { diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 9ce00a9bd..7ec91ad7f 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -1,5 +1,5 @@ import type { gmail_v1 } from "@googleapis/gmail"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM, ParsedMessage } from "@/utils/types"; import { aiCheckIfNeedsReply } from "@/utils/ai/reply/check-if-needs-reply"; import prisma from "@/utils/prisma"; @@ -12,18 +12,20 @@ import { labelMessage, removeThreadLabel } from "@/utils/gmail/label"; import { internalDateToDate } from "@/utils/date"; export async function handleOutboundReply( - user: UserEmailWithAI, + emailAccount: EmailAccountWithAI, message: ParsedMessage, gmail: gmail_v1.Gmail, ) { const logger = createScopedLogger("reply-tracker/outbound").with({ - email: user.email, + email: emailAccount.email, messageId: message.id, threadId: message.threadId, }); // 1. Check if feature enabled - const isEnabled = await isOutboundTrackingEnabled({ email: user.email }); + const isEnabled = await isOutboundTrackingEnabled({ + email: emailAccount.email, + }); if (!isEnabled) { logger.info("Outbound reply tracking disabled, skipping."); return; @@ -38,7 +40,7 @@ export async function handleOutboundReply( // 3. Resolve existing NEEDS_REPLY trackers for this thread await resolveReplyTrackers( gmail, - user.userId, + emailAccount.userId, message.threadId, needsReplyLabelId, ); @@ -69,7 +71,7 @@ export async function handleOutboundReply( // 7. Perform AI check const aiResult = await aiCheckIfNeedsReply({ - user, + emailAccount, messageToSend: messageToSendForLLM, threadContextMessages: threadContextMessagesForLLM, }); @@ -79,7 +81,7 @@ export async function handleOutboundReply( logger.info("Needs reply. Creating reply tracker outbound"); await createReplyTrackerOutbound({ gmail, - email: user.email, + emailAccountId: emailAccount.id, threadId: message.threadId, messageId: message.id, awaitingReplyLabelId, @@ -93,7 +95,7 @@ export async function handleOutboundReply( async function createReplyTrackerOutbound({ gmail, - email, + emailAccountId, threadId, messageId, awaitingReplyLabelId, @@ -101,7 +103,7 @@ async function createReplyTrackerOutbound({ logger, }: { gmail: gmail_v1.Gmail; - email: string; + emailAccountId: string; threadId: string; messageId: string; awaitingReplyLabelId: string; @@ -113,14 +115,14 @@ async function createReplyTrackerOutbound({ const upsertPromise = prisma.threadTracker.upsert({ where: { emailAccountId_threadId_messageId: { - emailAccountId: email, + emailAccountId, threadId, messageId, }, }, update: {}, create: { - emailAccountId: email, + emailAccountId, threadId, messageId, type: ThreadTrackerType.AWAITING, diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 0b991b26b..f50d87f15 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -9,7 +9,7 @@ import { } from "@prisma/client"; import { getUserCategoriesForNames } from "@/utils/category.server"; import { getActionRiskLevel, type RiskAction } from "@/utils/risk"; -import { hasExampleParams } from "@/app/(app)/[account]/automation/examples"; +import { hasExampleParams } from "@/app/(app)/[emailAccountId]/automation/examples"; const logger = createScopedLogger("rule"); diff --git a/apps/web/utils/user/get.ts b/apps/web/utils/user/get.ts index 871c856fc..8db6fd909 100644 --- a/apps/web/utils/user/get.ts +++ b/apps/web/utils/user/get.ts @@ -1,32 +1,44 @@ import prisma from "@/utils/prisma"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; export async function getAiUser({ - email, -}: { email: string }): Promise { + emailAccountId, +}: { emailAccountId: string }): Promise { return prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { + id: true, userId: true, email: true, about: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, }, }); } -export async function getAiUserWithTokens({ email }: { email: string }) { - const user = await prisma.emailAccount.findUnique({ - where: { email }, +export async function getEmailAccountWithAiAndTokens({ + emailAccountId, +}: { emailAccountId: string }) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, select: { + id: true, userId: true, email: true, about: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, account: { select: { access_token: true, @@ -36,11 +48,11 @@ export async function getAiUserWithTokens({ email }: { email: string }) { }, }); - if (!user) return null; + if (!emailAccount) return null; return { - ...user, - tokens: user?.account, + ...emailAccount, + tokens: emailAccount.account, }; } diff --git a/apps/web/utils/user/validate.ts b/apps/web/utils/user/validate.ts index 99b8f8567..ea54e7816 100644 --- a/apps/web/utils/user/validate.ts +++ b/apps/web/utils/user/validate.ts @@ -3,27 +3,36 @@ import { hasAiAccess } from "@/utils/premium"; import prisma from "@/utils/prisma"; export async function validateUserAndAiAccess({ - email, + emailAccountId, }: { - email: string; + emailAccountId: string; }) { const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { + id: true, userId: true, email: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, about: true, - user: { select: { premium: { select: { aiAutomationAccess: true } } } }, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + premium: { + select: { + aiAutomationAccess: true, + }, + }, + }, + }, }, }); if (!emailAccount) throw new SafeError("User not found"); const userHasAiAccess = hasAiAccess( emailAccount.user.premium?.aiAutomationAccess, - emailAccount.aiApiKey, + emailAccount.user.aiApiKey, ); if (!userHasAiAccess) throw new SafeError("Please upgrade for AI access"); From 2f2c88a41a071f5f060ece1634085ec0336dfbec Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:15:58 +0300 Subject: [PATCH 072/176] more fixes --- apps/web/app/api/ai/compose-autocomplete/route.ts | 4 ++-- apps/web/app/api/ai/summarise/route.ts | 4 ++-- apps/web/app/api/user/stats/route.ts | 11 ++++++----- apps/web/app/api/user/stats/tinybird/load/route.ts | 10 +++++----- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/web/app/api/ai/compose-autocomplete/route.ts b/apps/web/app/api/ai/compose-autocomplete/route.ts index 8dd1d4d4a..5f11448eb 100644 --- a/apps/web/app/api/ai/compose-autocomplete/route.ts +++ b/apps/web/app/api/ai/compose-autocomplete/route.ts @@ -2,12 +2,12 @@ import { NextResponse } from "next/server"; import { withEmailAccount } from "@/utils/middleware"; import { composeAutocompleteBody } from "@/app/api/ai/compose-autocomplete/validation"; import { chatCompletionStream } from "@/utils/llms"; -import { getAiUser } from "@/utils/user/get"; +import { getEmailAccountWithAi } from "@/utils/user/get"; export const POST = withEmailAccount(async (request) => { const emailAccountId = request.auth.emailAccountId; - const user = await getAiUser({ emailAccountId }); + const user = await getEmailAccountWithAi({ emailAccountId }); if (!user) return NextResponse.json({ error: "Not authenticated" }); diff --git a/apps/web/app/api/ai/summarise/route.ts b/apps/web/app/api/ai/summarise/route.ts index e6bc10b6a..597b4cd97 100644 --- a/apps/web/app/api/ai/summarise/route.ts +++ b/apps/web/app/api/ai/summarise/route.ts @@ -4,7 +4,7 @@ import { withEmailAccount } from "@/utils/middleware"; import { summariseBody } from "@/app/api/ai/summarise/validation"; import { getSummary } from "@/utils/redis/summary"; import { emailToContent } from "@/utils/mail"; -import { getAiUser } from "@/utils/user/get"; +import { getEmailAccountWithAi } from "@/utils/user/get"; export const POST = withEmailAccount(async (request) => { const emailAccountId = request.auth.emailAccountId; @@ -24,7 +24,7 @@ export const POST = withEmailAccount(async (request) => { const cachedSummary = await getSummary(prompt); if (cachedSummary) return new NextResponse(cachedSummary); - const userAi = await getAiUser({ emailAccountId }); + const userAi = await getEmailAccountWithAi({ emailAccountId }); if (!userAi) return NextResponse.json({ error: "User not found" }, { status: 404 }); diff --git a/apps/web/app/api/user/stats/route.ts b/apps/web/app/api/user/stats/route.ts index e35854d0f..eb665de67 100644 --- a/apps/web/app/api/user/stats/route.ts +++ b/apps/web/app/api/user/stats/route.ts @@ -1,9 +1,9 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; import { dateToSeconds } from "@/utils/date"; import { getMessages } from "@/utils/gmail/message"; -import { getGmailClientForEmail } from "@/utils/account"; +import { getGmailClientForEmailId } from "@/utils/account"; +import { withEmailAccount } from "@/utils/middleware"; export type StatsResponse = Awaited>; @@ -72,9 +72,10 @@ async function getStats(options: { gmail: gmail_v1.Gmail }) { }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; - const gmail = await getGmailClientForEmail({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + + const gmail = await getGmailClientForEmailId({ emailAccountId }); const result = await getStats({ gmail }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/tinybird/load/route.ts b/apps/web/app/api/user/stats/tinybird/load/route.ts index de7950067..0a57de486 100644 --- a/apps/web/app/api/user/stats/tinybird/load/route.ts +++ b/apps/web/app/api/user/stats/tinybird/load/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { loadEmails } from "@/app/api/user/stats/tinybird/load/load-emails"; import { loadTinybirdEmailsBody } from "@/app/api/user/stats/tinybird/load/validation"; import { getTokens } from "@/utils/account"; @@ -10,13 +10,13 @@ export const maxDuration = 90; export type LoadTinybirdEmailsResponse = Awaited>; -export const POST = withAuth(async (request) => { - const email = request.auth.userEmail; +export const POST = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const json = await request.json(); const body = loadTinybirdEmailsBody.parse(json); - const tokens = await getTokens({ email }); + const tokens = await getTokens({ emailAccountId }); const gmail = getGmailClient(tokens); const token = await getGmailAccessToken(tokens); @@ -26,7 +26,7 @@ export const POST = withAuth(async (request) => { const result = await loadEmails( { - emailAccountId: email, + emailAccountId, gmail, accessToken, }, From ead9ec523de0d0e50502ccc6acc9110566a7f5a5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 25 Apr 2025 19:16:51 +0300 Subject: [PATCH 073/176] more fixes --- .../(app)/[emailAccountId]/clean/helpers.ts | 12 +- .../app/(app)/[emailAccountId]/clean/page.tsx | 4 +- .../(app)/[emailAccountId]/clean/run/page.tsx | 10 +- .../smart-categories/page.tsx | 19 +- .../api/ai/analyze-sender-pattern/route.ts | 47 +-- apps/web/app/api/ai/models/route.ts | 20 +- .../api/google/labels/create/controller.ts | 10 +- .../google/webhook/process-history-item.ts | 19 +- .../app/api/google/webhook/process-history.ts | 34 ++- apps/web/app/api/google/webhook/types.ts | 5 +- .../disable-unused-auto-draft/route.ts | 22 +- apps/web/app/api/resend/summary/route.ts | 77 +++-- .../categorize/senders/uncategorized/route.ts | 8 +- apps/web/app/api/user/rules/route.ts | 12 +- .../api/user/settings/email-updates/route.ts | 12 +- .../api/user/settings/multi-account/route.ts | 8 +- apps/web/app/api/user/settings/route.ts | 10 +- apps/web/app/api/user/stats/day/route.ts | 14 +- .../app/api/user/stats/email-actions/route.ts | 24 +- .../app/api/user/stats/newsletters/route.ts | 10 +- .../user/stats/newsletters/summary/route.ts | 14 +- .../app/api/user/stats/sender-emails/route.ts | 8 +- apps/web/app/api/user/stats/senders/route.ts | 8 +- apps/web/app/api/user/stats/tinybird/route.ts | 31 +- apps/web/utils/account.ts | 29 +- apps/web/utils/actions/ai-rule.ts | 278 ++++++++++-------- apps/web/utils/actions/assess.ts | 28 +- apps/web/utils/actions/categorize.ts | 133 +++++---- apps/web/utils/actions/clean.ts | 124 ++++---- apps/web/utils/actions/cold-email.ts | 47 +-- apps/web/utils/actions/generate-reply.ts | 11 +- apps/web/utils/actions/group.ts | 40 +-- apps/web/utils/actions/knowledge.ts | 45 +-- apps/web/utils/actions/mail.ts | 180 +++++++----- apps/web/utils/actions/safe-action.ts | 18 +- apps/web/utils/actions/unsubscriber.ts | 6 +- apps/web/utils/actions/user.ts | 16 +- apps/web/utils/actions/webhook.ts | 4 +- apps/web/utils/actions/whitelist.ts | 4 +- apps/web/utils/ai/choose-rule/match-rules.ts | 12 +- apps/web/utils/category.server.ts | 18 +- apps/web/utils/config.ts | 2 + apps/web/utils/group/find-matching-group.ts | 6 +- apps/web/utils/group/group-item.ts | 6 +- apps/web/utils/middleware.ts | 59 +++- apps/web/utils/redis/label.ts | 44 ++- apps/web/utils/redis/message-processing.ts | 15 +- apps/web/utils/redis/reply.ts | 18 +- .../reply-tracker/check-previous-emails.ts | 12 +- .../web/utils/reply-tracker/draft-tracking.ts | 18 +- .../web/utils/reply-tracker/generate-draft.ts | 15 +- apps/web/utils/reply-tracker/inbound.ts | 20 +- apps/web/utils/reply-tracker/outbound.ts | 18 +- apps/web/utils/rule/prompt-file.ts | 39 ++- apps/web/utils/rule/rule.ts | 36 +-- apps/web/utils/unsubscribe.ts | 6 +- apps/web/utils/user/get.ts | 8 +- 57 files changed, 987 insertions(+), 766 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts b/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts index 94ae3b466..304602c10 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts +++ b/apps/web/app/(app)/[emailAccountId]/clean/helpers.ts @@ -1,20 +1,22 @@ import prisma from "utils/prisma"; export async function getJobById({ - email, + emailAccountId, jobId, }: { - email: string; + emailAccountId: string; jobId: string; }) { return await prisma.cleanupJob.findUnique({ - where: { id: jobId, email }, + where: { id: jobId, emailAccountId }, }); } -export async function getLastJob({ accountId }: { accountId: string }) { +export async function getLastJob({ + emailAccountId, +}: { emailAccountId: string }) { return await prisma.cleanupJob.findFirst({ - where: { emailAccount: { accountId } }, + where: { emailAccountId }, orderBy: { createdAt: "desc" }, }); } diff --git a/apps/web/app/(app)/[emailAccountId]/clean/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/page.tsx index 64957c849..a80864c22 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/page.tsx @@ -8,9 +8,9 @@ export default async function CleanPage({ }: { params: Promise<{ emailAccountId: string }>; }) { - const { account } = await params; + const { emailAccountId } = await params; - const lastJob = await getLastJob({ accountId: account }); + const lastJob = await getLastJob({ emailAccountId }); if (!lastJob) redirect("/clean/onboarding"); return ( diff --git a/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx index e9e60269f..3780f723f 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx @@ -19,7 +19,7 @@ export default async function CleanRunPage(props: { const { jobId, isPreviewBatch } = searchParams; const emailAccount = await prisma.emailAccount.findUnique({ - where: { accountId }, + where: { id: emailAccountId }, select: { email: true }, }); @@ -27,11 +27,11 @@ export default async function CleanRunPage(props: { const email = emailAccount.email; - const threads = await getThreadsByJobId({ emailAccountId: email, jobId }); + const threads = await getThreadsByJobId({ emailAccountId, jobId }); const job = jobId - ? await getJobById({ email, jobId }) - : await getLastJob({ accountId }); + ? await getJobById({ emailAccountId, jobId }) + : await getLastJob({ emailAccountId }); if (!job) return Job not found; @@ -40,7 +40,7 @@ export default async function CleanRunPage(props: { where: { jobId, emailAccountId: email }, }), prisma.cleanupThread.count({ - where: { jobId, emailAccountId: email, archived: true }, + where: { jobId, emailAccountId, archived: true }, }), ]); diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx index 70a0d887e..5ff58a15e 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx @@ -3,7 +3,6 @@ import { redirect } from "next/navigation"; import Link from "next/link"; import { PenIcon, SparklesIcon } from "lucide-react"; import sortBy from "lodash/sortBy"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { ClientOnly } from "@/components/ClientOnly"; import { GroupedTable } from "@/components/GroupedTable"; @@ -30,26 +29,28 @@ import { getCategorizationProgress } from "@/utils/redis/categorization-progress export const dynamic = "force-dynamic"; export const maxDuration = 300; -export default async function CategoriesPage() { - const session = await auth(); - const email = session?.user.email; - if (!email) throw new Error("Not authenticated"); +export default async function CategoriesPage({ + params, +}: { + params: Promise<{ emailAccountId: string }>; +}) { + const { emailAccountId } = await params; const [senders, categories, emailAccount, progress] = await Promise.all([ prisma.newsletter.findMany({ - where: { emailAccountId: email, categoryId: { not: null } }, + where: { emailAccountId, categoryId: { not: null } }, select: { id: true, email: true, category: { select: { id: true, description: true, name: true } }, }, }), - getUserCategoriesWithRules(email), + getUserCategoriesWithRules({ emailAccountId }), prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { autoCategorizeSenders: true }, }), - getCategorizationProgress({ email }), + getCategorizationProgress({ emailAccountId }), ]); if (!(senders.length > 0 || categories.length > 0)) diff --git a/apps/web/app/api/ai/analyze-sender-pattern/route.ts b/apps/web/app/api/ai/analyze-sender-pattern/route.ts index 8f74a636d..2712d7bb6 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/route.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/route.ts @@ -21,7 +21,7 @@ const MAX_RESULTS = 10; const logger = createScopedLogger("api/ai/pattern-match"); const schema = z.object({ - email: z.string(), + emailAccountId: z.string(), from: z.string(), }); export type AnalyzeSenderPatternBody = z.infer; @@ -34,13 +34,13 @@ export const POST = withError(async (request) => { const json = await request.json(); const data = schema.parse(json); - const { email } = data; + const { emailAccountId } = data; const from = extractEmailAddress(data.from); - logger.trace("Analyzing sender pattern", { email, from }); + logger.trace("Analyzing sender pattern", { emailAccountId, from }); // return immediately and process in background - after(() => process({ email, from })); + after(() => process({ emailAccountId, from })); return NextResponse.json({ processing: true }); }); @@ -52,12 +52,15 @@ export const POST = withError(async (request) => { * 4. Detects patterns using AI * 5. Stores patterns in DB for future categorization */ -async function process({ email, from }: { email: string; from: string }) { +async function process({ + emailAccountId, + from, +}: { emailAccountId: string; from: string }) { try { - const emailAccount = await getEmailAccountWithRules({ email }); + const emailAccount = await getEmailAccountWithRules({ emailAccountId }); if (!emailAccount) { - logger.error("Email account not found", { email }); + logger.error("Email account not found", { emailAccountId }); return NextResponse.json({ success: false }, { status: 404 }); } @@ -66,20 +69,20 @@ async function process({ email, from }: { email: string; from: string }) { where: { email_emailAccountId: { email: extractEmailAddress(from), - emailAccountId: emailAccount.email, + emailAccountId: emailAccount.id, }, }, }); if (existingCheck?.patternAnalyzed) { - logger.info("Sender has already been analyzed", { from, email }); + logger.info("Sender has already been analyzed", { from, emailAccountId }); return NextResponse.json({ success: true }); } const account = emailAccount.account; if (!account?.access_token || !account?.refresh_token) { - logger.error("No Gmail account found", { email }); + logger.error("No Gmail account found", { emailAccountId }); return NextResponse.json({ success: false }, { status: 404 }); } @@ -99,7 +102,7 @@ async function process({ email, from }: { email: string; from: string }) { if (threadsWithMessages.length === 0) { logger.info("No threads found from this sender", { from, - email, + emailAccountId, }); // Don't record a check since we didn't run the AI analysis @@ -114,7 +117,7 @@ async function process({ email, from }: { email: string; from: string }) { if (allMessages.length < THRESHOLD_EMAILS) { logger.info("Not enough emails found from this sender", { from, - email, + emailAccountId, count: allMessages.length, }); @@ -138,7 +141,7 @@ async function process({ email, from }: { email: string; from: string }) { if (patternResult?.matchedRule) { // Save pattern to DB (adds sender to rule's group) await saveLearnedPattern({ - email: emailAccount.email, + emailAccountId, from, ruleName: patternResult.matchedRule, }); @@ -151,7 +154,7 @@ async function process({ email, from }: { email: string; from: string }) { } catch (error) { logger.error("Error in pattern match API", { from, - email, + emailAccountId, error, }); @@ -238,11 +241,11 @@ async function getThreadsFromSender( } async function saveLearnedPattern({ - email, + emailAccountId, from, ruleName, }: { - email: string; + emailAccountId: string; from: string; ruleName: string; }) { @@ -250,14 +253,14 @@ async function saveLearnedPattern({ where: { name_emailAccountId: { name: ruleName, - emailAccountId: email, + emailAccountId, }, }, select: { id: true, groupId: true }, }); if (!rule) { - logger.error("Rule not found", { email, ruleName }); + logger.error("Rule not found", { emailAccountId, ruleName }); return; } @@ -267,7 +270,7 @@ async function saveLearnedPattern({ // Create a new group for this rule if one doesn't exist const newGroup = await prisma.group.create({ data: { - emailAccountId: email, + emailAccountId, name: ruleName, rule: { connect: { id: rule.id } }, }, @@ -293,9 +296,11 @@ async function saveLearnedPattern({ }); } -async function getEmailAccountWithRules({ email }: { email: string }) { +async function getEmailAccountWithRules({ + emailAccountId, +}: { emailAccountId: string }) { return await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { id: true, userId: true, diff --git a/apps/web/app/api/ai/models/route.ts b/apps/web/app/api/ai/models/route.ts index 9f2bdf15e..cb37595f9 100644 --- a/apps/web/app/api/ai/models/route.ts +++ b/apps/web/app/api/ai/models/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import OpenAI from "openai"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { Provider } from "@/utils/llms/config"; import { createScopedLogger } from "@/utils/logger"; @@ -14,26 +14,28 @@ async function getOpenAiModels({ apiKey }: { apiKey: string }) { const models = await openai.models.list(); - return models.data.filter((m) => m.id.startsWith("gpt-")); + return models.data; } -export const GET = withAuth(async (req) => { - const email = req.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, - select: { aiApiKey: true, aiProvider: true }, + where: { id: emailAccountId }, + select: { user: { select: { aiApiKey: true, aiProvider: true } } }, }); if ( !emailAccount || - !emailAccount.aiApiKey || - emailAccount.aiProvider !== Provider.OPEN_AI + !emailAccount.user.aiApiKey || + emailAccount.user.aiProvider !== Provider.OPEN_AI ) return NextResponse.json([]); try { - const result = await getOpenAiModels({ apiKey: emailAccount.aiApiKey }); + const result = await getOpenAiModels({ + apiKey: emailAccount.user.aiApiKey, + }); return NextResponse.json(result); } catch (error) { logger.error("Failed to get OpenAI models", { error }); diff --git a/apps/web/app/api/google/labels/create/controller.ts b/apps/web/app/api/google/labels/create/controller.ts index ce2e572b8..87165bf4e 100644 --- a/apps/web/app/api/google/labels/create/controller.ts +++ b/apps/web/app/api/google/labels/create/controller.ts @@ -14,12 +14,12 @@ export type CreateLabelResponse = Awaited>; export async function createLabel({ gmail, - email, + emailAccountId, name, description, }: { gmail: gmail_v1.Gmail; - email: string; + emailAccountId: string; name: string; description?: string; }) { @@ -31,7 +31,7 @@ export async function createLabel({ where: { gmailLabelId_emailAccountId: { gmailLabelId: label.id, - emailAccountId: email, + emailAccountId, }, }, update: {}, @@ -40,12 +40,12 @@ export async function createLabel({ description, gmailLabelId: label.id, enabled: true, - emailAccountId: email, + emailAccountId, }, }); const redisPromise = saveUserLabel({ - email, + emailAccountId, label: { id: label.id, name, description }, }); diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index 1cf02b819..3b954e8e0 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -31,7 +31,7 @@ export async function processHistoryItem( }: gmail_v1.Schema$HistoryMessageAdded | gmail_v1.Schema$HistoryLabelAdded, { gmail, - email: userEmail, + userEmail, emailAccount, accessToken, hasColdEmailAccess, @@ -42,6 +42,7 @@ export async function processHistoryItem( ) { const messageId = message?.id; const threadId = message?.threadId; + const emailAccountId = emailAccount.id; if (!messageId || !threadId) return; @@ -66,7 +67,7 @@ export async function processHistoryItem( prisma.executedRule.findUnique({ where: { unique_emailAccount_thread_message: { - emailAccountId: userEmail, + emailAccountId, threadId, messageId, }, @@ -123,7 +124,7 @@ export async function processHistoryItem( // check if unsubscribed const blocked = await blockUnsubscribedEmails({ from: message.headers.from, - emailAccountId: userEmail, + emailAccountId, gmail, messageId, }); @@ -168,7 +169,7 @@ export async function processHistoryItem( const sender = extractEmailAddress(message.headers.from); const existingSender = await prisma.newsletter.findUnique({ where: { - email_emailAccountId: { email: sender, emailAccountId: userEmail }, + email_emailAccountId: { email: sender, emailAccountId }, }, select: { category: true }, }); @@ -203,12 +204,12 @@ export async function processHistoryItem( } async function handleOutbound( - user: EmailAccountWithAI, + emailAccount: EmailAccountWithAI, message: ParsedMessage, gmail: gmail_v1.Gmail, ) { const loggerOptions = { - email: user.email, + email: emailAccount.email, messageId: message.id, threadId: message.threadId, }; @@ -219,11 +220,11 @@ async function handleOutbound( // The individual functions handle their own operational errors. const [trackingResult, outboundResult] = await Promise.allSettled([ trackSentDraftStatus({ - user: { id: user.userId, email: user.email }, + emailAccountId: emailAccount.id, message, gmail, }), - handleOutboundReply(user, message, gmail), + handleOutboundReply({ emailAccount, message, gmail }), ]); if (trackingResult.status === "rejected") { @@ -245,7 +246,7 @@ async function handleOutbound( try { await cleanupThreadAIDrafts({ threadId: message.threadId, - email: user.email, + emailAccountId: emailAccount.id, gmail, }); } catch (cleanupError) { diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index 1ec3c1b0f..c727efe3d 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -172,7 +172,8 @@ export async function processHistoryForUser( await processHistory({ history: history.history, - email, + userEmail: emailAccount.email, + emailAccountId: emailAccount.id, gmail, accessToken: emailAccount.account?.access_token, hasAutomationRules, @@ -188,7 +189,10 @@ export async function processHistoryForUser( }); // important to save this or we can get into a loop with never receiving history - await updateLastSyncedHistoryId(emailAccount.email, historyId.toString()); + await updateLastSyncedHistoryId({ + emailAccountId: emailAccount.id, + lastSyncedHistoryId: historyId.toString(), + }); } logger.info("Completed processing history", { decodedData }); @@ -215,7 +219,7 @@ export async function processHistoryForUser( } async function processHistory(options: ProcessHistoryOptions) { - const { history, email } = options; + const { history, userEmail, emailAccountId } = options; if (!history?.length) return; @@ -236,11 +240,11 @@ async function processHistory(options: ProcessHistoryOptions) { } catch (error) { captureException( error, - { extra: { email, messageId: m.message?.id } }, - email, + { extra: { userEmail, messageId: m.message?.id } }, + userEmail, ); logger.error("Error processing history item", { - email, + userEmail, messageId: m.message?.id, threadId: m.message?.threadId, error: @@ -258,16 +262,22 @@ async function processHistory(options: ProcessHistoryOptions) { const lastSyncedHistoryId = history[history.length - 1].id; - await updateLastSyncedHistoryId(email, lastSyncedHistoryId); + await updateLastSyncedHistoryId({ + emailAccountId, + lastSyncedHistoryId, + }); } -async function updateLastSyncedHistoryId( - email: string, - lastSyncedHistoryId?: string | null, -) { +async function updateLastSyncedHistoryId({ + emailAccountId, + lastSyncedHistoryId, +}: { + emailAccountId: string; + lastSyncedHistoryId?: string | null; +}) { if (!lastSyncedHistoryId) return; await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { lastSyncedHistoryId }, }); } diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts index 8c015f273..f8a5554c2 100644 --- a/apps/web/app/api/google/webhook/types.ts +++ b/apps/web/app/api/google/webhook/types.ts @@ -1,11 +1,12 @@ import type { gmail_v1 } from "@googleapis/gmail"; import type { RuleWithActionsAndCategories } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; -import type { EmailAccount, User } from "@prisma/client"; +import type { EmailAccount } from "@prisma/client"; export type ProcessHistoryOptions = { history: gmail_v1.Schema$History[]; - email: string; + userEmail: string; + emailAccountId: string; gmail: gmail_v1.Gmail; accessToken: string; rules: RuleWithActionsAndCategories[]; diff --git a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts index 4512a3bc2..e797e6271 100644 --- a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts +++ b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts @@ -26,7 +26,7 @@ async function disableUnusedAutoDrafts() { // TODO: may need to make this more efficient // Find all users who have the auto-draft feature enabled (have an Action of type DRAFT_EMAIL) - const usersWithAutoDraft = await prisma.emailAccount.findMany({ + const emailAccountsWithAutoDraft = await prisma.emailAccount.findMany({ where: { rules: { some: { @@ -40,7 +40,7 @@ async function disableUnusedAutoDrafts() { }, }, select: { - email: true, + id: true, rules: { where: { systemType: SystemType.TO_REPLY, @@ -53,23 +53,25 @@ async function disableUnusedAutoDrafts() { }); logger.info( - `Found ${usersWithAutoDraft.length} users with auto-draft enabled`, + `Found ${emailAccountsWithAutoDraft.length} users with auto-draft enabled`, ); const results = { - usersChecked: usersWithAutoDraft.length, + usersChecked: emailAccountsWithAutoDraft.length, usersDisabled: 0, errors: 0, }; // Process each user - for (const user of usersWithAutoDraft) { + for (const emailAccount of emailAccountsWithAutoDraft) { + const emailAccountId = emailAccount.id; + try { // Find the last 10 drafts created for the user const lastTenDrafts = await prisma.executedAction.findMany({ where: { executedRule: { - emailAccountId: user.email, + emailAccountId, rule: { systemType: SystemType.TO_REPLY, }, @@ -96,7 +98,7 @@ async function disableUnusedAutoDrafts() { // Skip if user has fewer than 10 drafts (not enough data to make a decision) if (lastTenDrafts.length < MAX_DRAFTS_TO_CHECK) { logger.info("Skipping user - only has few drafts", { - email: user.email, + emailAccountId, numDrafts: lastTenDrafts.length, }); continue; @@ -110,14 +112,14 @@ async function disableUnusedAutoDrafts() { // If none of the drafts were sent, disable auto-draft if (!anyDraftsSent) { logger.info("Disabling auto-draft for user - last 10 drafts not used", { - email: user.email, + emailAccountId, }); // Delete the DRAFT_EMAIL actions from all TO_REPLY rules await prisma.action.deleteMany({ where: { rule: { - emailAccountId: user.email, + emailAccountId, systemType: SystemType.TO_REPLY, }, type: ActionType.DRAFT_EMAIL, @@ -128,7 +130,7 @@ async function disableUnusedAutoDrafts() { results.usersDisabled++; } } catch (error) { - logger.error("Error processing user", { email: user.email, error }); + logger.error("Error processing user", { emailAccountId, error }); captureException(error); results.errors++; } diff --git a/apps/web/app/api/resend/summary/route.ts b/apps/web/app/api/resend/summary/route.ts index 1b76bf119..b1e652ee9 100644 --- a/apps/web/app/api/resend/summary/route.ts +++ b/apps/web/app/api/resend/summary/route.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import subHours from "date-fns/subHours"; import { sendSummaryEmail } from "@inboxzero/resend"; -import { withAuth, withError } from "@/utils/middleware"; +import { withEmailAccount, withError } from "@/utils/middleware"; import { env } from "@/env"; import { hasCronSecret } from "@/utils/cron"; import { captureException } from "@/utils/error"; @@ -18,10 +18,16 @@ export const maxDuration = 60; const logger = createScopedLogger("resend/summary"); -const sendSummaryEmailBody = z.object({ email: z.string() }); +const sendSummaryEmailBody = z.object({ emailAccountId: z.string() }); -async function sendEmail({ email, force }: { email: string; force?: boolean }) { - const loggerOptions = { email, force }; +async function sendEmail({ + emailAccountId, + force, +}: { + emailAccountId: string; + force?: boolean; +}) { + const loggerOptions = { emailAccountId, force }; logger.info("Sending summary email", loggerOptions); @@ -31,7 +37,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { if (!force) { const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { lastSummaryEmailAt: true }, }); @@ -52,9 +58,10 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { } } - const user = await prisma.emailAccount.findUnique({ - where: { email }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, select: { + email: true, coldEmails: { where: { createdAt: { gt: cutOffDate } } }, _count: { select: { @@ -74,10 +81,15 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { }, }); - if (user) { - logger.info("User found", loggerOptions); + if (!emailAccount) { + logger.error("Email account not found", loggerOptions); + return { success: false }; + } + + if (emailAccount) { + logger.info("Email account found", loggerOptions); } else { - logger.error("User not found or cutoff date is in the future", { + logger.error("Email account not found or cutoff date is in the future", { ...loggerOptions, cutOffDate, }); @@ -96,7 +108,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { prisma.threadTracker.groupBy({ by: ["type"], where: { - emailAccountId: email, + emailAccountId, resolved: false, }, _count: true, @@ -104,7 +116,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { // needs reply prisma.threadTracker.findMany({ where: { - emailAccountId: email, + emailAccountId, type: ThreadTrackerType.NEEDS_REPLY, resolved: false, }, @@ -115,7 +127,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { // awaiting reply prisma.threadTracker.findMany({ where: { - emailAccountId: email, + emailAccountId, type: ThreadTrackerType.AWAITING, resolved: false, // only show emails that are more than 3 days overdue @@ -142,12 +154,12 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { counts.map((count) => [count.type, count._count]), ); - const coldEmailers = user.coldEmails.map((e) => ({ + const coldEmailers = emailAccount.coldEmails.map((e) => ({ from: e.fromEmail, subject: "", sentAt: e.createdAt, })); - const pendingCount = user._count.executedRules; + const pendingCount = emailAccount._count.executedRules; // get messages const messageIds = [ @@ -161,8 +173,8 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { messagesCount: messageIds.length, }); - const messages = user.account.access_token - ? await getMessagesBatch(messageIds, user.account.access_token) + const messages = emailAccount.account.access_token + ? await getMessagesBatch(messageIds, emailAccount.account.access_token) : []; const messageMap = Object.fromEntries( @@ -214,11 +226,17 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION], }); - async function sendEmail({ email }: { email: string }) { - const token = await createUnsubscribeToken({ email }); + async function sendEmail({ + emailAccountId, + userEmail, + }: { + emailAccountId: string; + userEmail: string; + }) { + const token = await createUnsubscribeToken({ emailAccountId }); return sendSummaryEmail({ - to: email, + to: userEmail, emailProps: { baseUrl: env.NEXT_PUBLIC_BASE_URL, coldEmailers, @@ -235,9 +253,11 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { } await Promise.all([ - shouldSendEmail ? sendEmail({ email }) : Promise.resolve(), + shouldSendEmail + ? sendEmail({ emailAccountId, userEmail: emailAccount.email }) + : Promise.resolve(), prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { lastSummaryEmailAt: new Date() }, }), ]); @@ -245,13 +265,13 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { return { success: true }; } -export const GET = withAuth(async (request) => { +export const GET = withEmailAccount(async (request) => { // send to self - const email = request.auth.userEmail; + const emailAccountId = request.auth.emailAccountId; - logger.info("Sending summary email to user GET", { email }); + logger.info("Sending summary email to user GET", { emailAccountId }); - const result = await sendEmail({ email, force: true }); + const result = await sendEmail({ emailAccountId, force: true }); return NextResponse.json(result); }); @@ -273,11 +293,12 @@ export const POST = withError(async (request: Request) => { { status: 400 }, ); } + const { emailAccountId } = data; - logger.info("Sending summary email to user POST", { email: data.email }); + logger.info("Sending summary email to user POST", { emailAccountId }); try { - await sendEmail(data); + await sendEmail({ emailAccountId }); return NextResponse.json({ success: true }); } catch (error) { logger.error("Error sending summary email", { error }); diff --git a/apps/web/app/api/user/categorize/senders/uncategorized/route.ts b/apps/web/app/api/user/categorize/senders/uncategorized/route.ts index 843bff965..92e4250ed 100644 --- a/apps/web/app/api/user/categorize/senders/uncategorized/route.ts +++ b/apps/web/app/api/user/categorize/senders/uncategorized/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getUncategorizedSenders } from "@/app/api/user/categorize/senders/uncategorized/get-uncategorized-senders"; export type UncategorizedSendersResponse = { @@ -7,14 +7,14 @@ export type UncategorizedSendersResponse = { nextOffset?: number; }; -export const GET = withAuth(async (request) => { - const { userEmail } = request.auth; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const url = new URL(request.url); const offset = Number.parseInt(url.searchParams.get("offset") || "0"); const result = await getUncategorizedSenders({ - emailAccountId: userEmail, + emailAccountId, offset, }); diff --git a/apps/web/app/api/user/rules/route.ts b/apps/web/app/api/user/rules/route.ts index 864b66695..7d2bae0e9 100644 --- a/apps/web/app/api/user/rules/route.ts +++ b/apps/web/app/api/user/rules/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import prisma from "@/utils/prisma"; export type RulesResponse = Awaited>; -async function getRules({ email }: { email: string }) { +async function getRules({ emailAccountId }: { emailAccountId: string }) { return await prisma.rule.findMany({ - where: { emailAccountId: email }, + where: { emailAccountId }, include: { actions: true, group: { select: { name: true } }, @@ -16,8 +16,8 @@ async function getRules({ email }: { email: string }) { }); } -export const GET = withAuth(async (req) => { - const email = req.auth.userEmail; - const result = await getRules({ email }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + const result = await getRules({ emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/settings/email-updates/route.ts b/apps/web/app/api/user/settings/email-updates/route.ts index 1196251e5..9c3ae3396 100644 --- a/apps/web/app/api/user/settings/email-updates/route.ts +++ b/apps/web/app/api/user/settings/email-updates/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { type SaveEmailUpdateSettingsBody, saveEmailUpdateSettingsBody, @@ -11,22 +11,22 @@ export type SaveEmailUpdateSettingsResponse = Awaited< >; async function saveEmailUpdateSettings( - { email }: { email: string }, + { emailAccountId }: { emailAccountId: string }, { statsEmailFrequency, summaryEmailFrequency }: SaveEmailUpdateSettingsBody, ) { return await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { statsEmailFrequency, summaryEmailFrequency }, }); } -export const POST = withAuth(async (request) => { - const email = request.auth.userEmail; +export const POST = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const json = await request.json(); const body = saveEmailUpdateSettingsBody.parse(json); - const result = await saveEmailUpdateSettings({ email }, body); + const result = await saveEmailUpdateSettings({ emailAccountId }, body); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/settings/multi-account/route.ts b/apps/web/app/api/user/settings/multi-account/route.ts index 54b90e77f..c0b4336df 100644 --- a/apps/web/app/api/user/settings/multi-account/route.ts +++ b/apps/web/app/api/user/settings/multi-account/route.ts @@ -6,9 +6,9 @@ export type MultiAccountEmailsResponse = Awaited< ReturnType >; -async function getMultiAccountEmails({ email }: { email: string }) { +async function getMultiAccountEmails({ userId }: { userId: string }) { const user = await prisma.user.findUnique({ - where: { email }, + where: { id: userId }, select: { premium: { select: { @@ -26,9 +26,9 @@ async function getMultiAccountEmails({ email }: { email: string }) { } export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; + const userId = request.auth.userId; - const result = await getMultiAccountEmails({ email }); + const result = await getMultiAccountEmails({ userId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/settings/route.ts b/apps/web/app/api/user/settings/route.ts index bae1cb7cc..a97af3501 100644 --- a/apps/web/app/api/user/settings/route.ts +++ b/apps/web/app/api/user/settings/route.ts @@ -11,7 +11,7 @@ import { withAuth } from "@/utils/middleware"; export type SaveSettingsResponse = Awaited>; async function saveAISettings( - { email }: { email: string }, + { userId }: { userId: string }, { aiProvider, aiModel, aiApiKey }: SaveSettingsBody, ) { function getModel() { @@ -41,8 +41,8 @@ async function saveAISettings( } } - return await prisma.emailAccount.update({ - where: { email }, + return await prisma.user.update({ + where: { id: userId }, data: { aiProvider, aiModel: getModel(), @@ -52,12 +52,12 @@ async function saveAISettings( } export const POST = withAuth(async (request) => { - const email = request.auth.userEmail; + const userId = request.auth.userId; const json = await request.json(); const body = saveSettingsBody.parse(json); - const result = await saveAISettings({ email }, body); + const result = await saveAISettings({ userId }, body); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/day/route.ts b/apps/web/app/api/user/stats/day/route.ts index b08514582..c3db8644b 100644 --- a/apps/web/app/api/user/stats/day/route.ts +++ b/apps/web/app/api/user/stats/day/route.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import type { gmail_v1 } from "@googleapis/gmail"; -import { withAuth } from "@/utils/middleware"; +import { withAuth, withEmailAccount } from "@/utils/middleware"; import { dateToSeconds } from "@/utils/date"; import { getMessages } from "@/utils/gmail/message"; import { getGmailClientForEmail } from "@/utils/account"; @@ -18,12 +18,10 @@ const DAYS = 7; async function getPastSevenDayStats( options: { - email: string; + emailAccountId: string; gmail: gmail_v1.Gmail; } & StatsByDayQuery, ) { - // const { email } = options; - const today = new Date(); const sevenDaysAgo = new Date( today.getFullYear(), @@ -80,19 +78,19 @@ function getQuery(type: StatsByDayQuery["type"], date: Date) { } } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const { searchParams } = new URL(request.url); const type = searchParams.get("type"); const query = statsByDayQuery.parse({ type }); - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const result = await getPastSevenDayStats({ ...query, - email, gmail, + emailAccountId, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/email-actions/route.ts b/apps/web/app/api/user/stats/email-actions/route.ts index d2110128c..62e77c5a0 100644 --- a/apps/web/app/api/user/stats/email-actions/route.ts +++ b/apps/web/app/api/user/stats/email-actions/route.ts @@ -1,27 +1,27 @@ import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withAuth, withEmailAccount } from "@/utils/middleware"; import { getEmailActionsByDay } from "@inboxzero/tinybird"; export type EmailActionStatsResponse = Awaited< ReturnType >; -async function getEmailActionStats({ email }: { email: string }) { - const result = (await getEmailActionsByDay({ ownerEmail: email })).data.map( - (d) => ({ - date: d.date, - Archived: d.archive_count, - Deleted: d.delete_count, - }), - ); +async function getEmailActionStats({ userEmail }: { userEmail: string }) { + const result = ( + await getEmailActionsByDay({ ownerEmail: userEmail }) + ).data.map((d) => ({ + date: d.date, + Archived: d.archive_count, + Deleted: d.delete_count, + })); return { result }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const userEmail = request.auth.email; - const result = await getEmailActionStats({ email }); + const result = await getEmailActionStats({ userEmail }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index 0787ded1e..3ddfc4ba7 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { filterNewsletters, findAutoArchiveFilter, @@ -233,14 +233,8 @@ function getOrderByClause(orderBy: string): string { } } -export const GET = withAuth(async (request) => { +export const GET = withEmailAccount(async (request) => { const emailAccountId = request.auth.emailAccountId; - if (!emailAccountId) { - return NextResponse.json( - { error: "Email account ID is required", isKnownError: true }, - { status: 403 }, - ); - } const { searchParams } = new URL(request.url); const params = newsletterStatsQuery.parse({ diff --git a/apps/web/app/api/user/stats/newsletters/summary/route.ts b/apps/web/app/api/user/stats/newsletters/summary/route.ts index fb39685d7..06be7480c 100644 --- a/apps/web/app/api/user/stats/newsletters/summary/route.ts +++ b/apps/web/app/api/user/stats/newsletters/summary/route.ts @@ -1,14 +1,16 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; export type NewsletterSummaryResponse = Awaited< ReturnType >; -async function getNewsletterSummary({ email }: { email: string }) { +async function getNewsletterSummary({ + emailAccountId, +}: { emailAccountId: string }) { const result = await prisma.newsletter.groupBy({ - where: { emailAccountId: email }, + where: { emailAccountId }, by: ["status"], _count: true, }); @@ -20,10 +22,10 @@ async function getNewsletterSummary({ email }: { email: string }) { return { result: resultObject }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; - const result = await getNewsletterSummary({ email }); + const result = await getNewsletterSummary({ emailAccountId }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/sender-emails/route.ts b/apps/web/app/api/user/stats/sender-emails/route.ts index 3b4bb8c99..79dae129a 100644 --- a/apps/web/app/api/user/stats/sender-emails/route.ts +++ b/apps/web/app/api/user/stats/sender-emails/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import format from "date-fns/format"; import { zodPeriod } from "@inboxzero/tinybird"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import prisma from "@/utils/prisma"; import { Prisma } from "@prisma/client"; @@ -69,8 +69,8 @@ async function getSenderEmails( }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const { searchParams } = new URL(request.url); @@ -83,7 +83,7 @@ export const GET = withAuth(async (request) => { const result = await getSenderEmails({ ...query, - emailAccountId: email, + emailAccountId, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/senders/route.ts b/apps/web/app/api/user/stats/senders/route.ts index 8dc82d974..5f34c6aea 100644 --- a/apps/web/app/api/user/stats/senders/route.ts +++ b/apps/web/app/api/user/stats/senders/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getEmailFieldStats } from "@/app/api/user/stats/helpers"; const senderStatsQuery = z.object({ @@ -79,8 +79,8 @@ async function getDomainsMostReceivedFrom({ }); } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const { searchParams } = new URL(request.url); const query = senderStatsQuery.parse({ @@ -90,7 +90,7 @@ export const GET = withAuth(async (request) => { const result = await getSenderStatistics({ ...query, - emailAccountId: email, + emailAccountId, }); return NextResponse.json(result); diff --git a/apps/web/app/api/user/stats/tinybird/route.ts b/apps/web/app/api/user/stats/tinybird/route.ts index 96704cb9f..c479c309b 100644 --- a/apps/web/app/api/user/stats/tinybird/route.ts +++ b/apps/web/app/api/user/stats/tinybird/route.ts @@ -3,7 +3,7 @@ import format from "date-fns/format"; import { z } from "zod"; import sumBy from "lodash/sumBy"; import { zodPeriod } from "@inboxzero/tinybird"; -import { withAuth } from "@/utils/middleware"; +import { withAuth, withEmailAccount } from "@/utils/middleware"; import prisma from "@/utils/prisma"; import { Prisma } from "@prisma/client"; @@ -16,9 +16,9 @@ export type StatsByWeekParams = z.infer; export type StatsByWeekResponse = Awaited>; async function getEmailStatsByPeriod( - options: StatsByWeekParams & { userId: string }, + options: StatsByWeekParams & { emailAccountId: string }, ) { - const { period, fromDate, toDate, userId } = options; + const { period, fromDate, toDate, emailAccountId } = options; // Build date conditions without starting with AND const dateConditions: Prisma.Sql[] = []; @@ -51,7 +51,7 @@ async function getEmailStatsByPeriod( }; // Create WHERE clause properly - const whereClause = Prisma.sql`WHERE "userId" = ${userId}`; + const whereClause = Prisma.sql`WHERE "emailAccountId" = ${emailAccountId}`; const dateClause = dateConditions.length > 0 ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` @@ -89,24 +89,11 @@ async function getEmailStatsByPeriod( async function getStatsByPeriod( options: StatsByWeekParams & { - ownerEmail: string; + emailAccountId: string; }, ) { - // Get the userId from the ownerEmail - const user = await prisma.user.findUnique({ - where: { email: options.ownerEmail }, - select: { id: true }, - }); - - if (!user) { - throw new Error("User not found"); - } - // Get all stats in a single query - const stats = await getEmailStatsByPeriod({ - ...options, - userId: user.id, - }); + const stats = await getEmailStatsByPeriod(options); // Transform stats to match the expected format const formattedStats = stats.map((stat) => { @@ -138,8 +125,8 @@ async function getStatsByPeriod( }; } -export const GET = withAuth(async (request) => { - const email = request.auth.userEmail; +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const { searchParams } = new URL(request.url); const params = statsByWeekParams.parse({ @@ -149,8 +136,8 @@ export const GET = withAuth(async (request) => { }); const result = await getStatsByPeriod({ - ownerEmail: email, ...params, + emailAccountId, }); return NextResponse.json(result); diff --git a/apps/web/utils/account.ts b/apps/web/utils/account.ts index 2ebbe1d8b..7a5d88f62 100644 --- a/apps/web/utils/account.ts +++ b/apps/web/utils/account.ts @@ -1,9 +1,11 @@ import { getGmailClient } from "@/utils/gmail/client"; import prisma from "@/utils/prisma"; -export async function getTokens({ email }: { email: string }) { +export async function getTokens({ + emailAccountId, +}: { emailAccountId: string }) { const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { account: { select: { access_token: true, refresh_token: true } }, }, @@ -15,27 +17,28 @@ export async function getTokens({ email }: { email: string }) { }; } -export async function getGmailClientForEmail({ email }: { email: string }) { - const tokens = await getTokens({ email }); +export async function getGmailClientForEmail({ + emailAccountId, +}: { emailAccountId: string }) { + const tokens = await getTokens({ emailAccountId }); const gmail = getGmailClient(tokens); return gmail; } -export async function getGmailClientForAccountId({ - accountId, +export async function getGmailClientForEmailId({ + emailAccountId, }: { - accountId: string; + emailAccountId: string; }) { - const account = await prisma.account.findUnique({ - where: { id: accountId }, + const account = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, select: { - access_token: true, - refresh_token: true, + account: { select: { access_token: true, refresh_token: true } }, }, }); const gmail = getGmailClient({ - accessToken: account?.access_token ?? undefined, - refreshToken: account?.refresh_token ?? undefined, + accessToken: account?.account.access_token ?? undefined, + refreshToken: account?.account.refresh_token ?? undefined, }); return gmail; } diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index b19daa7cd..fa35e22ac 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -12,7 +12,6 @@ import { emailToContent, parseMessage } from "@/utils/mail"; import { getMessage, getMessages } from "@/utils/gmail/message"; import { executeAct } from "@/utils/ai/choose-rule/execute"; import { isDefined } from "@/utils/types"; -import { SafeError } from "@/utils/error"; import { createAutomationBody, reportAiMistakeBody, @@ -34,6 +33,7 @@ import { deleteRule, safeCreateRule, safeUpdateRule } from "@/utils/rule/rule"; import { getUserCategoriesForNames } from "@/utils/category.server"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; +import { getEmailAccountWithAi } from "@/utils/user/get"; const logger = createScopedLogger("ai-rule"); @@ -42,12 +42,14 @@ export const runRulesAction = actionClient .schema(runRulesBody) .action( async ({ - ctx: { email, emailAccount }, + ctx: { emailAccountId }, parsedInput: { messageId, threadId, rerun, isTest }, - }): Promise => { - const gmail = await getGmailClientForEmail({ email }); + }): Promise => { + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - if (!emailAccount) throw new SafeError("Email account not found"); + if (!emailAccount) return { error: "Email account not found" }; + + const gmail = await getGmailClientForEmail({ emailAccountId }); const fetchExecutedRule = !isTest && !rerun; @@ -57,7 +59,7 @@ export const runRulesAction = actionClient ? prisma.executedRule.findUnique({ where: { unique_emailAccount_thread_message: { - emailAccountId: emailAccount.email, + emailAccountId, threadId, messageId, }, @@ -91,7 +93,7 @@ export const runRulesAction = actionClient const rules = await prisma.rule.findMany({ where: { - emailAccountId: email, + emailAccountId, enabled: true, instructions: { not: null }, }, @@ -103,7 +105,7 @@ export const runRulesAction = actionClient gmail, message, rules, - user: emailAccount, + emailAccount, }); return result; @@ -113,52 +115,54 @@ export const runRulesAction = actionClient export const testAiCustomContentAction = actionClient .metadata({ name: "testAiCustomContent" }) .schema(testAiCustomContentBody) - .action( - async ({ ctx: { email, emailAccount }, parsedInput: { content } }) => { - if (!emailAccount) throw new SafeError("Email account not found"); + .action(async ({ ctx: { emailAccountId }, parsedInput: { content } }) => { + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - const gmail = await getGmailClientForEmail({ email }); + if (!emailAccount) return { error: "Email account not found" }; - const rules = await prisma.rule.findMany({ - where: { - emailAccountId: email, - enabled: true, - instructions: { not: null }, - }, - include: { actions: true, categoryFilters: true }, - }); + const gmail = await getGmailClientForEmail({ emailAccountId }); - const result = await runRules({ - isTest: true, - gmail, - message: { - id: "testMessageId", - threadId: "testThreadId", - snippet: content, - textPlain: content, - headers: { - date: new Date().toISOString(), - from: "", - to: "", - subject: "", - }, - historyId: "", - inline: [], - internalDate: new Date().toISOString(), + const rules = await prisma.rule.findMany({ + where: { + emailAccountId, + enabled: true, + instructions: { not: null }, + }, + include: { actions: true, categoryFilters: true }, + }); + + const result = await runRules({ + isTest: true, + gmail, + message: { + id: "testMessageId", + threadId: "testThreadId", + snippet: content, + textPlain: content, + headers: { + date: new Date().toISOString(), + from: "", + to: "", + subject: "", }, - rules, - user: emailAccount, - }); + historyId: "", + inline: [], + internalDate: new Date().toISOString(), + }, + rules, + emailAccount, + }); - return result; - }, - ); + return result; + }); export const createAutomationAction = actionClient .metadata({ name: "createAutomation" }) .schema(createAutomationBody) - .action(async ({ ctx: { email, emailAccount }, parsedInput: { prompt } }) => { - if (!emailAccount) throw new SafeError("Email account not found"); + .action(async ({ ctx: { emailAccountId }, parsedInput: { prompt } }) => { + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); + + if (!emailAccount) return { error: "Email account not found" }; let result: CreateOrUpdateRuleSchemaWithCategories; @@ -166,33 +170,44 @@ export const createAutomationAction = actionClient result = await aiCreateRule(prompt, emailAccount); } catch (error) { if (error instanceof Error) { - throw new SafeError(`AI error creating rule. ${error.message}`); + return { error: `AI error creating rule. ${error.message}` }; } - throw new SafeError("AI error creating rule."); + return { error: "AI error creating rule." }; } - if (!result) throw new SafeError("AI error creating rule."); + if (!result) return { error: "AI error creating rule." }; - const createdRule = await safeCreateRule({ result, email }); + const createdRule = await safeCreateRule({ + result, + emailAccountId, + }); return createdRule; }); export const setRuleRunOnThreadsAction = actionClient .metadata({ name: "setRuleRunOnThreads" }) .schema(z.object({ ruleId: z.string(), runOnThreads: z.boolean() })) - .action(async ({ ctx: { email }, parsedInput: { ruleId, runOnThreads } }) => { - await prisma.rule.update({ - where: { id: ruleId, emailAccountId: email }, - data: { runOnThreads }, - }); - }); + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { ruleId, runOnThreads }, + }) => { + await prisma.rule.update({ + where: { id: ruleId, emailAccountId }, + data: { runOnThreads }, + }); + }, + ); export const approvePlanAction = actionClient .metadata({ name: "approvePlan" }) .schema(z.object({ executedRuleId: z.string(), message: z.any() })) .action( - async ({ ctx: { email }, parsedInput: { executedRuleId, message } }) => { - const gmail = await getGmailClientForEmail({ email }); + async ({ + ctx: { emailAccountId, emailAccount }, + parsedInput: { executedRuleId, message }, + }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); const executedRule = await prisma.executedRule.findUnique({ where: { id: executedRuleId }, @@ -204,7 +219,7 @@ export const approvePlanAction = actionClient gmail, message, executedRule, - userEmail: email, + userEmail: emailAccount.email, }); }, ); @@ -212,12 +227,14 @@ export const approvePlanAction = actionClient export const rejectPlanAction = actionClient .metadata({ name: "rejectPlan" }) .schema(z.object({ executedRuleId: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { executedRuleId } }) => { - await prisma.executedRule.updateMany({ - where: { id: executedRuleId, emailAccountId: email }, - data: { status: ExecutedRuleStatus.REJECTED }, - }); - }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { executedRuleId } }) => { + await prisma.executedRule.updateMany({ + where: { id: executedRuleId, emailAccountId }, + data: { status: ExecutedRuleStatus.REJECTED }, + }); + }, + ); /** * Saves the user's rules prompt and updates the rules accordingly. @@ -236,20 +253,23 @@ export const rejectPlanAction = actionClient export const saveRulesPromptAction = actionClient .metadata({ name: "saveRulesPrompt" }) .schema(saveRulesPromptBody) - .action(async ({ ctx: { email }, parsedInput: { rulesPrompt } }) => { - logger.info("Starting saveRulesPromptAction", { email }); - + .action(async ({ ctx: { emailAccountId }, parsedInput: { rulesPrompt } }) => { const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { - rulesPrompt: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, + id: true, email: true, userId: true, about: true, + rulesPrompt: true, categories: { select: { id: true, name: true } }, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, }, }); @@ -260,12 +280,14 @@ export const saveRulesPromptAction = actionClient const oldPromptFile = emailAccount.rulesPrompt; logger.info("Old prompt file", { - email, + emailAccountId, exists: oldPromptFile ? "exists" : "does not exist", }); if (oldPromptFile === rulesPrompt) { - logger.info("No changes in rules prompt, returning early", { email }); + logger.info("No changes in rules prompt, returning early", { + emailAccountId, + }); return { createdRules: 0, editedRules: 0, removedRules: 0 }; } @@ -275,15 +297,15 @@ export const saveRulesPromptAction = actionClient // check how the prompts have changed, and make changes to the rules accordingly if (oldPromptFile) { - logger.info("Comparing old and new prompts", { email }); + logger.info("Comparing old and new prompts", { emailAccountId }); const diff = await aiDiffRules({ - user: emailAccount, + emailAccount, oldPromptFile, newPromptFile: rulesPrompt, }); logger.info("Diff results", { - email, + emailAccountId, addedRules: diff.addedRules.length, editedRules: diff.editedRules.length, removedRules: diff.removedRules.length, @@ -294,36 +316,38 @@ export const saveRulesPromptAction = actionClient !diff.editedRules.length && !diff.removedRules.length ) { - logger.info("No changes detected in rules, returning early", { email }); + logger.info("No changes detected in rules, returning early", { + emailAccountId, + }); return { createdRules: 0, editedRules: 0, removedRules: 0 }; } if (diff.addedRules.length) { - logger.info("Processing added rules", { email }); + logger.info("Processing added rules", { emailAccountId }); addedRules = await aiPromptToRules({ - user: emailAccount, + emailAccount, promptFile: diff.addedRules.join("\n\n"), isEditing: false, availableCategories: emailAccount.categories.map((c) => c.name), }); logger.info("Added rules", { - email, + emailAccountId, addedRules: addedRules?.length || 0, }); } // find existing rules const userRules = await prisma.rule.findMany({ - where: { emailAccountId: email, enabled: true }, + where: { emailAccountId, enabled: true }, include: { actions: true }, }); logger.info("Found existing user rules", { - email, + emailAccountId, count: userRules.length, }); const existingRules = await aiFindExistingRules({ - user: emailAccount, + emailAccount, promptRulesToEdit: diff.editedRules, promptRulesToRemove: diff.removedRules, databaseRules: userRules, @@ -331,21 +355,21 @@ export const saveRulesPromptAction = actionClient // remove rules logger.info("Processing rules for removal", { - email, + emailAccountId, count: existingRules.removedRules.length, }); for (const rule of existingRules.removedRules) { if (!rule.rule) { - logger.error("Rule not found.", { email }); + logger.error("Rule not found.", { emailAccountId }); continue; } const executedRule = await prisma.executedRule.findFirst({ - where: { emailAccountId: email, ruleId: rule.rule.id }, + where: { emailAccountId, ruleId: rule.rule.id }, }); logger.info("Removing rule", { - email, + emailAccountId, promptRule: rule.promptRule, ruleName: rule.rule.name, ruleId: rule.rule.id, @@ -353,20 +377,20 @@ export const saveRulesPromptAction = actionClient if (executedRule) { await prisma.rule.update({ - where: { id: rule.rule.id, emailAccountId: email }, + where: { id: rule.rule.id, emailAccountId }, data: { enabled: false }, }); } else { try { await deleteRule({ ruleId: rule.rule.id, - email, + emailAccountId, groupId: rule.rule.groupId, }); } catch (error) { if (!isNotFoundError(error)) { logger.error("Error deleting rule", { - email, + emailAccountId, ruleId: rule.rule.id, error: error instanceof Error ? error.message : "Unknown error", }); @@ -380,7 +404,7 @@ export const saveRulesPromptAction = actionClient // edit rules if (existingRules.editedRules.length > 0) { const editedRules = await aiPromptToRules({ - user: emailAccount, + emailAccount, promptFile: existingRules.editedRules .map( (r) => `Rule ID: ${r.rule?.id}. Prompt: ${r.updatedPromptRule}`, @@ -393,20 +417,20 @@ export const saveRulesPromptAction = actionClient for (const rule of editedRules) { if (!rule.ruleId) { logger.error("Rule ID not found for rule", { - email, + emailAccountId, promptRule: rule.name, }); continue; } logger.info("Editing rule", { - email, + emailAccountId, promptRule: rule.name, ruleId: rule.ruleId, }); const categoryIds = await getUserCategoriesForNames({ - email, + emailAccountId, names: rule.condition.categories?.categoryFilters || [], }); @@ -415,21 +439,21 @@ export const saveRulesPromptAction = actionClient await safeUpdateRule({ ruleId: rule.ruleId, result: rule, - email, + emailAccountId, categoryIds, }); } } } else { - logger.info("Processing new rules prompt with AI", { email }); + logger.info("Processing new rules prompt with AI", { emailAccountId }); addedRules = await aiPromptToRules({ - user: emailAccount, + emailAccount, promptFile: rulesPrompt, isEditing: false, availableCategories: emailAccount.categories.map((c) => c.name), }); logger.info("Rules to be added", { - email, + emailAccountId, count: addedRules?.length || 0, }); } @@ -437,26 +461,26 @@ export const saveRulesPromptAction = actionClient // add new rules for (const rule of addedRules || []) { logger.info("Creating rule", { - email, + emailAccountId, promptRule: rule.name, ruleId: rule.ruleId, }); await safeCreateRule({ result: rule, - email, + emailAccountId, categoryNames: rule.condition.categories?.categoryFilters || [], }); } // update rules prompt for user await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { rulesPrompt }, }); logger.info("Completed", { - email, + emailAccountId, createdRules: addedRules?.length || 0, editedRules: editRulesCount, removedRules: removeRulesCount, @@ -480,10 +504,12 @@ export const saveRulesPromptAction = actionClient export const generateRulesPromptAction = actionClient .metadata({ name: "generateRulesPrompt" }) .schema(z.object({})) - .action(async ({ ctx: { email, emailAccount } }) => { - if (!emailAccount) throw new SafeError("Email account not found"); + .action(async ({ ctx: { emailAccountId } }) => { + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); + + if (!emailAccount) return { error: "Email account not found" }; - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const lastSent = await getMessages(gmail, { query: "in:sent", maxResults: 50, @@ -519,7 +545,7 @@ export const generateRulesPromptAction = actionClient }); const snippetsResult = await aiFindSnippets({ - user: emailAccount, + emailAccount, sentEmails: lastSentMessages.map((message) => ({ id: message.id, from: message.headers.from, @@ -531,13 +557,13 @@ export const generateRulesPromptAction = actionClient }); const result = await aiGenerateRulesPrompt({ - user: emailAccount, + emailAccount, lastSentEmails, snippets: snippetsResult.snippets.map((snippet) => snippet.text), userLabels: labelsWithCounts.map((label) => label.label), }); - if (!result) throw new SafeError("Error generating rules prompt"); + if (!result) return { error: "Error generating rules prompt" }; return { rulesPrompt: result.join("\n\n") }; }); @@ -545,44 +571,48 @@ export const generateRulesPromptAction = actionClient export const setRuleEnabledAction = actionClient .metadata({ name: "setRuleEnabled" }) .schema(z.object({ ruleId: z.string(), enabled: z.boolean() })) - .action(async ({ ctx: { email }, parsedInput: { ruleId, enabled } }) => { - await prisma.rule.update({ - where: { id: ruleId, emailAccountId: email }, - data: { enabled }, - }); - }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { ruleId, enabled } }) => { + await prisma.rule.update({ + where: { id: ruleId, emailAccountId }, + data: { enabled }, + }); + }, + ); export const reportAiMistakeAction = actionClient .metadata({ name: "reportAiMistake" }) .schema(reportAiMistakeBody) .action( async ({ - ctx: { email, emailAccount }, + ctx: { emailAccountId }, parsedInput: { expectedRuleId, actualRuleId, explanation, message }, }) => { - if (!emailAccount) throw new SafeError("Email account not found"); + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); + + if (!emailAccount) return { error: "Email account not found" }; if (!expectedRuleId && !actualRuleId) - throw new SafeError("Either correct or incorrect rule ID is required"); + return { error: "Either correct or incorrect rule ID is required" }; const [expectedRule, actualRule] = await Promise.all([ expectedRuleId ? prisma.rule.findUnique({ - where: { id: expectedRuleId, emailAccountId: email }, + where: { id: expectedRuleId, emailAccountId }, }) : null, actualRuleId ? prisma.rule.findUnique({ - where: { id: actualRuleId, emailAccountId: email }, + where: { id: actualRuleId, emailAccountId }, }) : null, ]); if (expectedRuleId && !expectedRule) - throw new SafeError("Expected rule not found"); + return { error: "Expected rule not found" }; if (actualRuleId && !actualRule) - throw new SafeError("Actual rule not found"); + return { error: "Actual rule not found" }; const content = emailToContent({ textHtml: message.textHtml || undefined, @@ -591,7 +621,7 @@ export const reportAiMistakeAction = actionClient }); const result = await aiRuleFix({ - user: emailAccount, + emailAccount, actualRule, expectedRule, email: { @@ -602,7 +632,7 @@ export const reportAiMistakeAction = actionClient explanation: explanation?.trim() || undefined, }); - if (!result) throw new SafeError("Error fixing rule"); + if (!result) return { error: "Error fixing rule" }; return { ruleId: diff --git a/apps/web/utils/actions/assess.ts b/apps/web/utils/actions/assess.ts index 4b785815e..9bd3c78f8 100644 --- a/apps/web/utils/actions/assess.ts +++ b/apps/web/utils/actions/assess.ts @@ -13,11 +13,11 @@ import { SafeError } from "@/utils/error"; // to help with onboarding and provide the best flow to new users export const assessAction = actionClient .metadata({ name: "assessUser" }) - .action(async ({ ctx: { email } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action(async ({ ctx: { emailAccountId } }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { behaviorProfile: true }, }); @@ -25,7 +25,7 @@ export const assessAction = actionClient const result = await assessUser({ gmail }); await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { behaviorProfile: result }, }); @@ -34,13 +34,25 @@ export const assessAction = actionClient export const analyzeWritingStyleAction = actionClient .metadata({ name: "analyzeWritingStyle" }) - .action(async ({ ctx: { email, emailAccount } }) => { + .action(async ({ ctx: { emailAccountId } }) => { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + writingStyle: true, + id: true, + userId: true, + email: true, + about: true, + user: { select: { aiProvider: true, aiModel: true, aiApiKey: true } }, + }, + }); + if (!emailAccount) throw new SafeError("Email account not found"); if (emailAccount?.writingStyle) return { success: true, skipped: true }; // fetch last 20 sent emails - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const sentMessages = await getSentMessages(gmail, 20); // analyze writing style @@ -48,7 +60,7 @@ export const analyzeWritingStyleAction = actionClient emails: sentMessages.map((email) => getEmailForLLM(email, { extractReply: true }), ), - user: emailAccount, + emailAccount, }); if (!style) return; @@ -69,7 +81,7 @@ export const analyzeWritingStyleAction = actionClient .join("\n"); await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { writingStyle }, }); diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 4ac61e39a..fe8986939 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -29,20 +29,22 @@ const logger = createScopedLogger("actions/categorize"); export const bulkCategorizeSendersAction = actionClient .metadata({ name: "bulkCategorizeSenders" }) - .action(async ({ ctx: { email } }) => { - await validateUserAndAiAccess({ email }); + .action(async ({ ctx: { emailAccountId, userEmail } }) => { + await validateUserAndAiAccess({ emailAccountId }); // Delete empty queues as Qstash has a limit on how many queues we can have // We could run this in a cron too but simplest to do here for now - deleteEmptyCategorizeSendersQueues({ skipEmail: email }).catch((error) => { - logger.error("Error deleting empty queues", { error }); - }); + deleteEmptyCategorizeSendersQueues({ skipEmail: userEmail }).catch( + (error) => { + logger.error("Error deleting empty queues", { error }); + }, + ); const LIMIT = 100; async function getUncategorizedSenders(offset: number) { const result = await getSenders({ - emailAccountId: email, + emailAccountId, limit: LIMIT, offset, }); @@ -52,7 +54,7 @@ export const bulkCategorizeSendersAction = actionClient const existingSenders = await prisma.newsletter.findMany({ where: { email: { in: allSenders }, - emailAccountId: email, + emailAccountId, category: { isNot: null }, }, select: { email: true }, @@ -71,7 +73,7 @@ export const bulkCategorizeSendersAction = actionClient const newUncategorizedSenders = await getUncategorizedSenders(i * LIMIT); logger.trace("Got uncategorized senders", { - email, + emailAccountId, uncategorizedSenders: newUncategorizedSenders.length, }); @@ -80,13 +82,13 @@ export const bulkCategorizeSendersAction = actionClient totalUncategorizedSenders += newUncategorizedSenders.length; await saveCategorizationTotalItems({ - email, + emailAccountId, totalItems: totalUncategorizedSenders, }); // publish to qstash await publishToAiCategorizeSendersQueue({ - email, + emailAccountId, senders: uncategorizedSenders, }); @@ -94,7 +96,7 @@ export const bulkCategorizeSendersAction = actionClient } logger.info("Queued senders for categorization", { - email, + emailAccountId, totalUncategorizedSenders, }); @@ -105,10 +107,13 @@ export const categorizeSenderAction = actionClient .metadata({ name: "categorizeSender" }) .schema(z.object({ senderAddress: z.string() })) .action( - async ({ ctx: { email, session }, parsedInput: { senderAddress } }) => { - const gmail = await getGmailClientForEmail({ email }); + async ({ + ctx: { emailAccountId, session }, + parsedInput: { senderAddress }, + }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); - const userResult = await validateUserAndAiAccess({ email }); + const userResult = await validateUserAndAiAccess({ emailAccountId }); const { emailAccount } = userResult; if (!session.accessToken) throw new SafeError("No access token"); @@ -129,20 +134,25 @@ export const categorizeSenderAction = actionClient export const changeSenderCategoryAction = actionClient .metadata({ name: "changeSenderCategory" }) .schema(z.object({ sender: z.string(), categoryId: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { sender, categoryId } }) => { - const category = await prisma.category.findUnique({ - where: { id: categoryId, emailAccountId: email }, - }); - if (!category) return { error: "Category not found" }; + .action( + async ({ + ctx: { emailAccountId, emailAccount }, + parsedInput: { sender, categoryId }, + }) => { + const category = await prisma.category.findUnique({ + where: { id: categoryId, emailAccountId }, + }); + if (!category) return { error: "Category not found" }; - await updateCategoryForSender({ - userEmail: email, - sender, - categoryId, - }); + await updateCategoryForSender({ + emailAccountId, + sender, + categoryId, + }); - revalidatePath("/smart-categories"); - }); + revalidatePath("/smart-categories"); + }, + ); export const upsertDefaultCategoriesAction = actionClient .metadata({ name: "upsertDefaultCategories" }) @@ -157,16 +167,19 @@ export const upsertDefaultCategoriesAction = actionClient ), }), ) - .action(async ({ ctx: { email }, parsedInput: { categories } }) => { + .action(async ({ ctx: { emailAccountId }, parsedInput: { categories } }) => { for (const { id, name, enabled } of categories) { const description = Object.values(defaultCategory).find( (c) => c.name === name, )?.description; if (enabled) { - await upsertCategory({ email, newCategory: { name, description } }); + await upsertCategory({ + emailAccountId, + newCategory: { name, description }, + }); } else { - if (id) await deleteCategory({ email, categoryId: id }); + if (id) await deleteCategory({ emailAccountId, categoryId: id }); } } @@ -176,44 +189,49 @@ export const upsertDefaultCategoriesAction = actionClient export const createCategoryAction = actionClient .metadata({ name: "createCategory" }) .schema(createCategoryBody) - .action(async ({ ctx: { email }, parsedInput: { name, description } }) => { - await upsertCategory({ email, newCategory: { name, description } }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { name, description } }) => { + await upsertCategory({ + emailAccountId, + newCategory: { name, description }, + }); - revalidatePath("/smart-categories"); - }); + revalidatePath("/smart-categories"); + }, + ); export const deleteCategoryAction = actionClient .metadata({ name: "deleteCategory" }) .schema(z.object({ categoryId: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { categoryId } }) => { - await deleteCategory({ email, categoryId }); + .action(async ({ ctx: { emailAccountId }, parsedInput: { categoryId } }) => { + await deleteCategory({ emailAccountId, categoryId }); revalidatePath("/smart-categories"); }); async function deleteCategory({ - email, + emailAccountId, categoryId, }: { - email: string; + emailAccountId: string; categoryId: string; }) { await prisma.category.delete({ - where: { id: categoryId, emailAccountId: email }, + where: { id: categoryId, emailAccountId }, }); } async function upsertCategory({ - email, + emailAccountId, newCategory, }: { - email: string; + emailAccountId: string; newCategory: CreateCategoryBody; }) { try { if (newCategory.id) { const category = await prisma.category.update({ - where: { id: newCategory.id, emailAccountId: email }, + where: { id: newCategory.id, emailAccountId }, data: { name: newCategory.name, description: newCategory.description, @@ -224,7 +242,7 @@ async function upsertCategory({ } else { const category = await prisma.category.create({ data: { - emailAccountId: email, + emailAccountId, name: newCategory.name, description: newCategory.description, }, @@ -244,27 +262,30 @@ export const setAutoCategorizeAction = actionClient .metadata({ name: "setAutoCategorize" }) .schema(z.object({ autoCategorizeSenders: z.boolean() })) .action( - async ({ ctx: { email }, parsedInput: { autoCategorizeSenders } }) => { + async ({ + ctx: { emailAccountId }, + parsedInput: { autoCategorizeSenders }, + }) => { await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { autoCategorizeSenders }, }); - - return { autoCategorizeSenders }; }, ); export const removeAllFromCategoryAction = actionClient .metadata({ name: "removeAllFromCategory" }) .schema(z.object({ categoryName: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { categoryName } }) => { - await prisma.newsletter.updateMany({ - where: { - category: { name: categoryName }, - emailAccountId: email, - }, - data: { categoryId: null }, - }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { categoryName } }) => { + await prisma.newsletter.updateMany({ + where: { + category: { name: categoryName }, + emailAccountId, + }, + data: { categoryId: null }, + }); - revalidatePath("/smart-categories"); - }); + revalidatePath("/smart-categories"); + }, + ); diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index 66b7840cb..406e23ecd 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -23,7 +23,6 @@ import prisma from "@/utils/prisma"; import { CleanAction } from "@prisma/client"; import { updateThread } from "@/utils/redis/clean"; import { getUnhandledCount } from "@/utils/assess"; -import { hash } from "@/utils/hash"; import { getGmailClientForEmail } from "@/utils/account"; import { actionClient } from "@/utils/actions/safe-action"; import { SafeError } from "@/utils/error"; @@ -35,10 +34,10 @@ export const cleanInboxAction = actionClient .schema(cleanInboxSchema) .action( async ({ - ctx: { email }, + ctx: { emailAccountId }, parsedInput: { action, instructions, daysOld, skips, maxEmails }, }) => { - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const [markedDoneLabel, processedLabel] = await Promise.all([ getOrCreateInboxZeroLabel({ @@ -62,7 +61,7 @@ export const cleanInboxAction = actionClient // create a cleanup job const job = await prisma.cleanupJob.create({ data: { - email, + emailAccountId, action, instructions, daysOld, @@ -129,7 +128,7 @@ export const cleanInboxAction = actionClient }); logger.info("Fetched threads", { - email, + emailAccountId, threadCount: threads.length, nextPageToken, }); @@ -141,7 +140,7 @@ export const cleanInboxAction = actionClient const url = `${env.WEBHOOK_URL || env.NEXT_PUBLIC_BASE_URL}/api/clean`; logger.info("Pushing to Qstash", { - email, + emailAccountId, threadCount: threads.length, nextPageToken, }); @@ -152,7 +151,7 @@ export const cleanInboxAction = actionClient return { url, body: { - email, + emailAccountId, threadId: thread.id, markedDoneLabelId, processedLabelId, @@ -160,13 +159,12 @@ export const cleanInboxAction = actionClient action, instructions, skips, - labels: [], } satisfies CleanThreadBody, // give every user their own queue for ai processing. if we get too many parallel users we may need more // api keys or a global queue // problem with a global queue is that if there's a backlog users will have to wait for others to finish first flowControl: { - key: `ai-clean-${hash(email)}`, + key: `ai-clean-${emailAccountId}`, parallelism: 3, }, }; @@ -198,10 +196,10 @@ export const undoCleanInboxAction = actionClient .schema(undoCleanInboxSchema) .action( async ({ - ctx: { email }, + ctx: { emailAccountId }, parsedInput: { threadId, markedDone, action }, }) => { - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); // nothing to do atm if wasn't marked done if (!markedDone) return { success: true }; @@ -231,13 +229,13 @@ export const undoCleanInboxAction = actionClient try { // We need to get the thread first to get the jobId const thread = await prisma.cleanupThread.findFirst({ - where: { emailAccountId: email, threadId }, + where: { emailAccountId, threadId }, orderBy: { createdAt: "desc" }, }); if (thread) { await updateThread({ - email, + emailAccountId, jobId: thread.jobId, threadId, update: { @@ -261,59 +259,61 @@ export const undoCleanInboxAction = actionClient export const changeKeepToDoneAction = actionClient .metadata({ name: "changeKeepToDone" }) .schema(changeKeepToDoneSchema) - .action(async ({ ctx: { email }, parsedInput: { threadId, action } }) => { - const gmail = await getGmailClientForEmail({ email }); - - // Get the label to add (archived or marked_read) - const actionLabel = await getOrCreateInboxZeroLabel({ - key: action === CleanAction.ARCHIVE ? "archived" : "marked_read", - gmail, - }); - - await labelThread({ - gmail, - threadId, - // Apply the action (archive or mark as read) - removeLabelIds: [ - ...(action === CleanAction.ARCHIVE ? [GmailLabel.INBOX] : []), - ...(action === CleanAction.MARK_READ ? [GmailLabel.UNREAD] : []), - ], - addLabelIds: [...(actionLabel?.id ? [actionLabel.id] : [])], - }); - - // Update Redis to mark this thread with the new status - try { - // We need to get the thread first to get the jobId - const thread = await prisma.cleanupThread.findFirst({ - where: { emailAccountId: email, threadId }, - orderBy: { createdAt: "desc" }, + .action( + async ({ ctx: { emailAccountId }, parsedInput: { threadId, action } }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); + + // Get the label to add (archived or marked_read) + const actionLabel = await getOrCreateInboxZeroLabel({ + key: action === CleanAction.ARCHIVE ? "archived" : "marked_read", + gmail, + }); + + await labelThread({ + gmail, + threadId, + // Apply the action (archive or mark as read) + removeLabelIds: [ + ...(action === CleanAction.ARCHIVE ? [GmailLabel.INBOX] : []), + ...(action === CleanAction.MARK_READ ? [GmailLabel.UNREAD] : []), + ], + addLabelIds: [...(actionLabel?.id ? [actionLabel.id] : [])], }); - if (thread) { - // await updateThread(userId, thread.jobId, threadId, { - // archive: action === CleanAction.ARCHIVE, - // status: "completed", - // undone: true, - // }); + // Update Redis to mark this thread with the new status + try { + // We need to get the thread first to get the jobId + const thread = await prisma.cleanupThread.findFirst({ + where: { emailAccountId, threadId }, + orderBy: { createdAt: "desc" }, + }); + + if (thread) { + // await updateThread(userId, thread.jobId, threadId, { + // archive: action === CleanAction.ARCHIVE, + // status: "completed", + // undone: true, + // }); - await updateThread({ - email, - jobId: thread.jobId, + await updateThread({ + emailAccountId, + jobId: thread.jobId, + threadId, + update: { + archive: action === CleanAction.ARCHIVE, + status: "completed", + undone: true, + }, + }); + } + } catch (error) { + logger.error("Failed to update Redis for changed thread:", { + error, threadId, - update: { - archive: action === CleanAction.ARCHIVE, - status: "completed", - undone: true, - }, }); + // Continue even if Redis update fails } - } catch (error) { - logger.error("Failed to update Redis for changed thread:", { - error, - threadId, - }); - // Continue even if Redis update fails - } - return { success: true }; - }); + return { success: true }; + }, + ); diff --git a/apps/web/utils/actions/cold-email.ts b/apps/web/utils/actions/cold-email.ts index d23766024..1162be57e 100644 --- a/apps/web/utils/actions/cold-email.ts +++ b/apps/web/utils/actions/cold-email.ts @@ -22,34 +22,38 @@ import { SafeError } from "@/utils/error"; export const updateColdEmailSettingsAction = actionClient .metadata({ name: "updateColdEmailSettings" }) .schema(updateColdEmailSettingsBody) - .action(async ({ ctx: { email }, parsedInput: { coldEmailBlocker } }) => { - await prisma.emailAccount.update({ - where: { email }, - data: { coldEmailBlocker }, - }); - }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { coldEmailBlocker } }) => { + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { coldEmailBlocker }, + }); + }, + ); export const updateColdEmailPromptAction = actionClient .metadata({ name: "updateColdEmailPrompt" }) .schema(updateColdEmailPromptBody) - .action(async ({ ctx: { email }, parsedInput: { coldEmailPrompt } }) => { - await prisma.emailAccount.update({ - where: { email }, - data: { coldEmailPrompt }, - }); - }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { coldEmailPrompt } }) => { + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { coldEmailPrompt }, + }); + }, + ); export const markNotColdEmailAction = actionClient .metadata({ name: "markNotColdEmail" }) .schema(markNotColdEmailBody) - .action(async ({ ctx: { email }, parsedInput: { sender } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action(async ({ ctx: { emailAccountId }, parsedInput: { sender } }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); await Promise.all([ prisma.coldEmail.update({ where: { emailAccountId_fromEmail: { - emailAccountId: email, + emailAccountId, fromEmail: sender, }, }, @@ -93,7 +97,7 @@ export const testColdEmailAction = actionClient .schema(coldEmailBlockerBody) .action( async ({ - ctx: { email, emailAccount }, + ctx: { emailAccountId }, parsedInput: { from, subject, @@ -105,9 +109,16 @@ export const testColdEmailAction = actionClient date, }, }) => { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + include: { + user: { select: { aiProvider: true, aiModel: true, aiApiKey: true } }, + }, + }); + if (!emailAccount) throw new SafeError("Email account not found"); - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const content = emailToContent({ textHtml: textHtml || undefined, @@ -124,7 +135,7 @@ export const testColdEmailAction = actionClient threadId: threadId || undefined, id: messageId || "", }, - user: emailAccount, + emailAccount, gmail, }); diff --git a/apps/web/utils/actions/generate-reply.ts b/apps/web/utils/actions/generate-reply.ts index e6fe29cd1..69eb88bd3 100644 --- a/apps/web/utils/actions/generate-reply.ts +++ b/apps/web/utils/actions/generate-reply.ts @@ -5,15 +5,18 @@ import { aiGenerateNudge } from "@/utils/ai/reply/generate-nudge"; import { emailToContent } from "@/utils/mail"; import { getReply, saveReply } from "@/utils/redis/reply"; import { actionClient } from "@/utils/actions/safe-action"; +import { getEmailAccountWithAi } from "@/utils/user/get"; export const generateNudgeReplyAction = actionClient .metadata({ name: "generateNudgeReply" }) .schema(generateReplySchema) .action( async ({ - ctx: { email, emailAccount }, + ctx: { emailAccountId }, parsedInput: { messages: inputMessages }, }) => { + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); + if (!emailAccount) return { error: "User not found" }; const lastMessage = inputMessages.at(-1); @@ -21,7 +24,7 @@ export const generateNudgeReplyAction = actionClient if (!lastMessage) return { error: "No message provided" }; const reply = await getReply({ - email, + emailAccountId, messageId: lastMessage.id, }); @@ -37,9 +40,9 @@ export const generateNudgeReplyAction = actionClient }), })); - const text = await aiGenerateNudge({ messages, user: emailAccount }); + const text = await aiGenerateNudge({ messages, emailAccount }); await saveReply({ - email, + emailAccountId, messageId: lastMessage.id, reply: text, }); diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index e9104936d..fab141ad3 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -13,9 +13,9 @@ import { actionClient } from "@/utils/actions/safe-action"; export const createGroupAction = actionClient .metadata({ name: "createGroup" }) .schema(createGroupBody) - .action(async ({ ctx: { email }, parsedInput: { ruleId } }) => { + .action(async ({ ctx: { emailAccountId }, parsedInput: { ruleId } }) => { const rule = await prisma.rule.findUnique({ - where: { id: ruleId, emailAccountId: email }, + where: { id: ruleId, emailAccountId }, select: { name: true, groupId: true }, }); if (rule?.groupId) return { groupId: rule.groupId }; @@ -24,7 +24,7 @@ export const createGroupAction = actionClient const group = await prisma.group.create({ data: { name: rule.name, - emailAccountId: email, + emailAccountId, rule: { connect: { id: ruleId }, }, @@ -37,27 +37,31 @@ export const createGroupAction = actionClient export const addGroupItemAction = actionClient .metadata({ name: "addGroupItem" }) .schema(addGroupItemBody) - .action(async ({ ctx: { email }, parsedInput: { groupId, type, value } }) => { - const group = await prisma.group.findUnique({ - where: { id: groupId }, - }); - if (!group) return { error: "Group not found" }; - if (group.emailAccountId !== email) - return { error: "You don't have permission to add items to this group" }; + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { groupId, type, value }, + }) => { + const group = await prisma.group.findUnique({ + where: { id: groupId }, + }); + if (!group) return { error: "Group not found" }; + if (group.emailAccountId !== emailAccountId) + return { + error: "You don't have permission to add items to this group", + }; - await addGroupItem({ groupId, type, value }); + await addGroupItem({ groupId, type, value }); - revalidatePath("/automation"); - }); + revalidatePath("/automation"); + }, + ); export const deleteGroupItemAction = actionClient .metadata({ name: "deleteGroupItem" }) .schema(z.object({ id: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { id } }) => { - await deleteGroupItem({ id, email }); - if (!email) return { error: "Not logged in" }; - - await deleteGroupItem({ id, email }); + .action(async ({ ctx: { emailAccountId }, parsedInput: { id } }) => { + await deleteGroupItem({ id, emailAccountId }); revalidatePath("/automation"); }); diff --git a/apps/web/utils/actions/knowledge.ts b/apps/web/utils/actions/knowledge.ts index 57ecb7bc1..415e3ddea 100644 --- a/apps/web/utils/actions/knowledge.ts +++ b/apps/web/utils/actions/knowledge.ts @@ -12,36 +12,43 @@ import { actionClient } from "@/utils/actions/safe-action"; export const createKnowledgeAction = actionClient .metadata({ name: "createKnowledge" }) .schema(createKnowledgeBody) - .action(async ({ ctx: { email }, parsedInput: { title, content } }) => { - await prisma.knowledge.create({ - data: { - title, - content, - emailAccountId: email, - }, - }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { title, content } }) => { + await prisma.knowledge.create({ + data: { + title, + content, + emailAccountId, + }, + }); - revalidatePath("/automation"); - }); + revalidatePath("/automation"); + }, + ); export const updateKnowledgeAction = actionClient .metadata({ name: "updateKnowledge" }) .schema(updateKnowledgeBody) - .action(async ({ ctx: { email }, parsedInput: { id, title, content } }) => { - await prisma.knowledge.update({ - where: { id, emailAccountId: email }, - data: { title, content }, - }); + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { id, title, content }, + }) => { + await prisma.knowledge.update({ + where: { id, emailAccountId }, + data: { title, content }, + }); - revalidatePath("/automation"); - }); + revalidatePath("/automation"); + }, + ); export const deleteKnowledgeAction = actionClient .metadata({ name: "deleteKnowledge" }) .schema(deleteKnowledgeBody) - .action(async ({ ctx: { email }, parsedInput: { id } }) => { + .action(async ({ ctx: { emailAccountId }, parsedInput: { id } }) => { await prisma.knowledge.delete({ - where: { id, emailAccountId: email }, + where: { id, emailAccountId }, }); revalidatePath("/automation"); diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index c6eb70840..36b5ba369 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { createLabel } from "@/app/api/google/labels/create/controller"; import prisma from "@/utils/prisma"; import { saveUserLabels } from "@/utils/redis/label"; -import { trashMessage, trashThread } from "@/utils/gmail/trash"; +import { trashThread } from "@/utils/gmail/trash"; import { archiveThread, markImportantMessage, @@ -26,41 +26,51 @@ const isStatusOk = (status: number) => status >= 200 && status < 300; export const archiveThreadAction = actionClient .metadata({ name: "archiveThread" }) .schema(z.object({ threadId: z.string(), labelId: z.string().optional() })) - .action(async ({ ctx: { email }, parsedInput: { threadId, labelId } }) => { - const gmail = await getGmailClientForEmail({ email }); - - const res = await archiveThread({ - gmail, - threadId, - ownerEmail: email, - actionSource: "user", - labelId, - }); - - if (!isStatusOk(res.status)) return { error: "Failed to archive thread" }; - }); + .action( + async ({ + ctx: { emailAccountId, emailAccount }, + parsedInput: { threadId, labelId }, + }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); + + const res = await archiveThread({ + gmail, + threadId, + ownerEmail: emailAccount.email, + actionSource: "user", + labelId, + }); + + if (!isStatusOk(res.status)) return { error: "Failed to archive thread" }; + }, + ); export const trashThreadAction = actionClient .metadata({ name: "trashThread" }) .schema(z.object({ threadId: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { threadId } }) => { - const gmail = await getGmailClientForEmail({ email }); - - const res = await trashThread({ - gmail, - threadId, - ownerEmail: email, - actionSource: "user", - }); - - if (!isStatusOk(res.status)) return { error: "Failed to delete thread" }; - }); + .action( + async ({ + ctx: { emailAccountId, emailAccount }, + parsedInput: { threadId }, + }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); + + const res = await trashThread({ + gmail, + threadId, + ownerEmail: emailAccount.email, + actionSource: "user", + }); + + if (!isStatusOk(res.status)) return { error: "Failed to delete thread" }; + }, + ); // export const trashMessageAction = actionClient // .metadata({ name: "trashMessage" }) // .schema(z.object({ messageId: z.string() })) -// .action(async ({ ctx: { email }, parsedInput: { messageId } }) => { -// const gmail = await getGmailClientForEmail({ email }); +// .action(async ({ ctx: { emailAccountId }, parsedInput: { messageId } }) => { +// const gmail = await getGmailClientForEmail({ emailAccountId }); // const res = await trashMessage({ gmail, messageId }); @@ -70,32 +80,39 @@ export const trashThreadAction = actionClient export const markReadThreadAction = actionClient .metadata({ name: "markReadThread" }) .schema(z.object({ threadId: z.string(), read: z.boolean() })) - .action(async ({ ctx: { email }, parsedInput: { threadId, read } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { threadId, read } }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); - const res = await markReadThread({ gmail, threadId, read }); + const res = await markReadThread({ gmail, threadId, read }); - if (!isStatusOk(res.status)) - return { error: "Failed to mark thread as read" }; - }); + if (!isStatusOk(res.status)) + return { error: "Failed to mark thread as read" }; + }, + ); export const markImportantMessageAction = actionClient .metadata({ name: "markImportantMessage" }) .schema(z.object({ messageId: z.string(), important: z.boolean() })) - .action(async ({ ctx: { email }, parsedInput: { messageId, important } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { messageId, important }, + }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); - const res = await markImportantMessage({ gmail, messageId, important }); + const res = await markImportantMessage({ gmail, messageId, important }); - if (!isStatusOk(res.status)) - return { error: "Failed to mark message as important" }; - }); + if (!isStatusOk(res.status)) + return { error: "Failed to mark message as important" }; + }, + ); export const markSpamThreadAction = actionClient .metadata({ name: "markSpamThread" }) .schema(z.object({ threadId: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { threadId } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action(async ({ ctx: { emailAccountId }, parsedInput: { threadId } }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); const res = await markSpam({ gmail, threadId }); @@ -106,37 +123,47 @@ export const markSpamThreadAction = actionClient export const createAutoArchiveFilterAction = actionClient .metadata({ name: "createAutoArchiveFilter" }) .schema(z.object({ from: z.string(), gmailLabelId: z.string().optional() })) - .action(async ({ ctx: { email }, parsedInput: { from, gmailLabelId } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { from, gmailLabelId }, + }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); - const res = await createAutoArchiveFilter({ gmail, from, gmailLabelId }); + const res = await createAutoArchiveFilter({ gmail, from, gmailLabelId }); - if (!isStatusOk(res.status)) - return { error: "Failed to create auto archive filter" }; - }); + if (!isStatusOk(res.status)) + return { error: "Failed to create auto archive filter" }; + }, + ); export const createFilterAction = actionClient .metadata({ name: "createFilter" }) .schema(z.object({ from: z.string(), gmailLabelId: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { from, gmailLabelId } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { from, gmailLabelId }, + }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); - const res = await createFilter({ - gmail, - from, - addLabelIds: [gmailLabelId], - }); + const res = await createFilter({ + gmail, + from, + addLabelIds: [gmailLabelId], + }); - if (!isStatusOk(res.status)) return { error: "Failed to create filter" }; + if (!isStatusOk(res.status)) return { error: "Failed to create filter" }; - return res; - }); + return res; + }, + ); export const deleteFilterAction = actionClient .metadata({ name: "deleteFilter" }) .schema(z.object({ id: z.string() })) - .action(async ({ ctx: { email }, parsedInput: { id } }) => { - const gmail = await getGmailClientForEmail({ email }); + .action(async ({ ctx: { emailAccountId }, parsedInput: { id } }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); const res = await deleteFilter({ gmail, id }); @@ -146,12 +173,19 @@ export const deleteFilterAction = actionClient export const createLabelAction = actionClient .metadata({ name: "createLabel" }) .schema(z.object({ name: z.string(), description: z.string().optional() })) - .action(async ({ ctx: { email }, parsedInput: { name, description } }) => { - const gmail = await getGmailClientForEmail({ email }); - - const label = await createLabel({ gmail, email, name, description }); - return label; - }); + .action( + async ({ ctx: { emailAccountId }, parsedInput: { name, description } }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); + + const label = await createLabel({ + gmail, + emailAccountId, + name, + description, + }); + return label; + }, + ); export const updateLabelsAction = actionClient .metadata({ name: "updateLabels" }) @@ -167,7 +201,7 @@ export const updateLabelsAction = actionClient ), }), ) - .action(async ({ ctx: { email }, parsedInput: { labels } }) => { + .action(async ({ ctx: { emailAccountId }, parsedInput: { labels } }) => { const enabledLabels = labels.filter((label) => label.enabled); const disabledLabels = labels.filter((label) => !label.enabled); @@ -176,13 +210,13 @@ export const updateLabelsAction = actionClient const { name, description, enabled, gmailLabelId } = label; return prisma.label.upsert({ - where: { name_emailAccountId: { name, emailAccountId: email } }, + where: { name_emailAccountId: { name, emailAccountId } }, create: { gmailLabelId, name, description, enabled, - emailAccount: { connect: { email } }, + emailAccountId, }, update: { name, @@ -193,14 +227,14 @@ export const updateLabelsAction = actionClient }), prisma.label.deleteMany({ where: { - emailAccountId: email, + emailAccountId, name: { in: disabledLabels.map((label) => label.name) }, }, }), ]); await saveUserLabels({ - email, + emailAccountId, labels: enabledLabels.map((l) => ({ ...l, id: l.gmailLabelId, @@ -211,8 +245,8 @@ export const updateLabelsAction = actionClient export const sendEmailAction = actionClient .metadata({ name: "sendEmail" }) .schema(sendEmailBody) - .action(async ({ ctx: { email }, parsedInput }) => { - const gmail = await getGmailClientForEmail({ email }); + .action(async ({ ctx: { emailAccountId }, parsedInput }) => { + const gmail = await getGmailClientForEmail({ emailAccountId }); const result = await sendEmailWithHtml(gmail, parsedInput); diff --git a/apps/web/utils/actions/safe-action.ts b/apps/web/utils/actions/safe-action.ts index 5fec40f57..799edd6a1 100644 --- a/apps/web/utils/actions/safe-action.ts +++ b/apps/web/utils/actions/safe-action.ts @@ -30,7 +30,7 @@ const baseClient = createSafeActionClient({ // }); export const actionClient = baseClient - .bindArgsSchemas<[activeEmail: z.ZodString]>([z.string()]) + .bindArgsSchemas<[emailAccountId: z.ZodString]>([z.string()]) .use(async ({ next, metadata, bindArgsClientInputs }) => { const session = await auth(); @@ -39,15 +39,13 @@ export const actionClient = baseClient if (!userEmail) throw new SafeError("Unauthorized"); const userId = session.user.id; - const email = bindArgsClientInputs[0] as string; + const emailAccountId = bindArgsClientInputs[0] as string; // validate user owns this email - const emailAccount = email - ? await prisma.emailAccount.findUnique({ - where: { email }, - }) - : null; - if (email && emailAccount?.userId !== userId) + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + }); + if (!emailAccount || emailAccount?.userId !== userId) throw new SafeError("Unauthorized"); return withServerActionInstrumentation(metadata?.name, async () => { @@ -56,7 +54,7 @@ export const actionClient = baseClient userId, userEmail, session, - email, + emailAccountId, emailAccount, }, }); @@ -68,8 +66,6 @@ export const actionClientUser = baseClient.use(async ({ next, metadata }) => { const session = await auth(); if (!session?.user) throw new SafeError("Unauthorized"); - const userEmail = session.user.email; - if (!userEmail) throw new SafeError("Unauthorized"); const userId = session.user.id; diff --git a/apps/web/utils/actions/unsubscriber.ts b/apps/web/utils/actions/unsubscriber.ts index 25fe30dd6..e9a6bc59c 100644 --- a/apps/web/utils/actions/unsubscriber.ts +++ b/apps/web/utils/actions/unsubscriber.ts @@ -11,18 +11,18 @@ export const setNewsletterStatusAction = actionClient .action( async ({ parsedInput: { newsletterEmail, status }, - ctx: { email: userEmail }, + ctx: { emailAccountId }, }) => { const email = extractEmailAddress(newsletterEmail); return await prisma.newsletter.upsert({ where: { - email_emailAccountId: { email, emailAccountId: userEmail }, + email_emailAccountId: { email, emailAccountId }, }, create: { status, email, - emailAccountId: userEmail, + emailAccountId, }, update: { status }, }); diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index 34ca9d1f1..9af79b699 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -18,9 +18,9 @@ export type SaveAboutBody = z.infer; export const saveAboutAction = actionClient .metadata({ name: "saveAbout" }) .schema(saveAboutBody) - .action(async ({ parsedInput: { about }, ctx: { email } }) => { + .action(async ({ parsedInput: { about }, ctx: { emailAccountId } }) => { await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { about }, }); @@ -33,9 +33,9 @@ export type SaveSignatureBody = z.infer; export const saveSignatureAction = actionClient .metadata({ name: "saveSignature" }) .schema(saveSignatureBody) - .action(async ({ parsedInput: { signature }, ctx: { email } }) => { + .action(async ({ parsedInput: { signature }, ctx: { emailAccountId } }) => { await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { signature }, }); @@ -44,9 +44,9 @@ export const saveSignatureAction = actionClient export const loadSignatureFromGmailAction = actionClient .metadata({ name: "loadSignatureFromGmail" }) - .action(async ({ ctx: { email } }) => { + .action(async ({ ctx: { emailAccountId } }) => { // 1. find last 5 sent emails - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); const messages = await getMessages(gmail, { query: "from:me", maxResults: 5, @@ -71,9 +71,9 @@ export const loadSignatureFromGmailAction = actionClient export const resetAnalyticsAction = actionClient .metadata({ name: "resetAnalytics" }) - .action(async ({ ctx: { email } }) => { + .action(async ({ ctx: { emailAccountId } }) => { await prisma.emailMessage.deleteMany({ - where: { emailAccountId: email }, + where: { emailAccountId }, }); }); diff --git a/apps/web/utils/actions/webhook.ts b/apps/web/utils/actions/webhook.ts index 102422e3d..3b397a6a1 100644 --- a/apps/web/utils/actions/webhook.ts +++ b/apps/web/utils/actions/webhook.ts @@ -6,11 +6,11 @@ import { actionClient } from "@/utils/actions/safe-action"; export const regenerateWebhookSecretAction = actionClient .metadata({ name: "regenerateWebhookSecret" }) - .action(async ({ ctx: { email } }) => { + .action(async ({ ctx: { emailAccountId } }) => { const webhookSecret = generateWebhookSecret(); await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { webhookSecret }, }); diff --git a/apps/web/utils/actions/whitelist.ts b/apps/web/utils/actions/whitelist.ts index 4ed01a86f..1872239a8 100644 --- a/apps/web/utils/actions/whitelist.ts +++ b/apps/web/utils/actions/whitelist.ts @@ -8,10 +8,10 @@ import { getGmailClientForEmail } from "@/utils/account"; export const whitelistInboxZeroAction = actionClient .metadata({ name: "whitelistInboxZero" }) - .action(async ({ ctx: { email } }) => { + .action(async ({ ctx: { emailAccountId } }) => { if (!env.WHITELIST_FROM) return; - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); await createFilter({ gmail, diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index ab386e71d..74d770fd0 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -75,20 +75,20 @@ async function findPotentialMatchingRules({ // groups singleton let groups: Awaited>; // only load once and only when needed - async function getGroups({ email }: { email: string }) { - if (!groups) groups = await getGroupsWithRules({ email }); + async function getGroups({ emailAccountId }: { emailAccountId: string }) { + if (!groups) groups = await getGroupsWithRules({ emailAccountId }); return groups; } // sender singleton let sender: { categoryId: string | null } | null | undefined; - async function getSender({ email }: { email: string }) { + async function getSender({ emailAccountId }: { emailAccountId: string }) { if (typeof sender === "undefined") { sender = await prisma.newsletter.findUnique({ where: { email_emailAccountId: { email: extractEmailAddress(message.headers.from), - emailAccountId: email, + emailAccountId, }, }, select: { categoryId: true }, @@ -112,7 +112,7 @@ async function findPotentialMatchingRules({ if (rule.groupId) { const { matchingItem, group } = await matchesGroupRule( rule, - await getGroups({ email: rule.emailAccountId }), + await getGroups({ emailAccountId: rule.emailAccountId }), message, ); if (matchingItem) { @@ -149,7 +149,7 @@ async function findPotentialMatchingRules({ if (conditionTypes.CATEGORY) { const matchedCategory = await matchesCategoryRule( rule, - await getSender({ email: rule.emailAccountId }), + await getSender({ emailAccountId: rule.emailAccountId }), ); if (matchedCategory) { unmatchedConditions.delete(ConditionType.CATEGORY); diff --git a/apps/web/utils/category.server.ts b/apps/web/utils/category.server.ts index c2704d603..4868783a4 100644 --- a/apps/web/utils/category.server.ts +++ b/apps/web/utils/category.server.ts @@ -10,16 +10,20 @@ export type CategoryWithRules = Prisma.CategoryGetPayload<{ }; }>; -export const getUserCategories = async ({ email }: { email: string }) => { +export const getUserCategories = async ({ + emailAccountId, +}: { emailAccountId: string }) => { const categories = await prisma.category.findMany({ - where: { emailAccountId: email }, + where: { emailAccountId }, }); return categories; }; -export const getUserCategoriesWithRules = async (email: string) => { +export const getUserCategoriesWithRules = async ({ + emailAccountId, +}: { emailAccountId: string }) => { const categories = await prisma.category.findMany({ - where: { emailAccountId: email }, + where: { emailAccountId }, select: { id: true, name: true, @@ -31,16 +35,16 @@ export const getUserCategoriesWithRules = async (email: string) => { }; export const getUserCategoriesForNames = async ({ - email, + emailAccountId, names, }: { - email: string; + emailAccountId: string; names: string[]; }) => { if (!names.length) return []; const categories = await prisma.category.findMany({ - where: { emailAccountId: email, name: { in: names } }, + where: { emailAccountId, name: { in: names } }, select: { id: true }, }); if (categories.length !== names.length) { diff --git a/apps/web/utils/config.ts b/apps/web/utils/config.ts index 311f1dd8f..d201adece 100644 --- a/apps/web/utils/config.ts +++ b/apps/web/utils/config.ts @@ -1,5 +1,7 @@ export const AI_GENERATED_FIELD_VALUE = "___AI_GENERATE___"; +export const EMAIL_ACCOUNT_HEADER = "X-Email-Account-ID"; + export const userCount = "10,000+"; export const ConditionType = { diff --git a/apps/web/utils/group/find-matching-group.ts b/apps/web/utils/group/find-matching-group.ts index 4bc29adcb..93b069ec2 100644 --- a/apps/web/utils/group/find-matching-group.ts +++ b/apps/web/utils/group/find-matching-group.ts @@ -4,9 +4,11 @@ import type { ParsedMessage } from "@/utils/types"; import { type GroupItem, GroupItemType } from "@prisma/client"; type GroupsWithRules = Awaited>; -export async function getGroupsWithRules({ email }: { email: string }) { +export async function getGroupsWithRules({ + emailAccountId, +}: { emailAccountId: string }) { return prisma.group.findMany({ - where: { emailAccountId: email, rule: { isNot: null } }, + where: { emailAccountId, rule: { isNot: null } }, include: { items: true, rule: { include: { actions: true } } }, }); } diff --git a/apps/web/utils/group/group-item.ts b/apps/web/utils/group/group-item.ts index adcf61e77..ae8cdac08 100644 --- a/apps/web/utils/group/group-item.ts +++ b/apps/web/utils/group/group-item.ts @@ -20,12 +20,12 @@ export async function addGroupItem(data: { export async function deleteGroupItem({ id, - email, + emailAccountId, }: { id: string; - email: string; + emailAccountId: string; }) { await prisma.groupItem.delete({ - where: { id, group: { emailAccountId: email } }, + where: { id, group: { emailAccountId } }, }); } diff --git a/apps/web/utils/middleware.ts b/apps/web/utils/middleware.ts index d3df734b9..726fbccfc 100644 --- a/apps/web/utils/middleware.ts +++ b/apps/web/utils/middleware.ts @@ -5,7 +5,8 @@ import { env } from "@/env"; import { logErrorToPosthog } from "@/utils/error.server"; import { createScopedLogger } from "@/utils/logger"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { validateUserAccount } from "@/utils/redis/account-validation"; +import { getEmailAccount } from "@/utils/redis/account-validation"; +import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; const logger = createScopedLogger("middleware"); @@ -18,7 +19,14 @@ export type NextHandler = ( export interface RequestWithAuth extends NextRequest { auth: { userId: string; - userEmail: string; + }; +} + +export interface RequestWithEmailAccount extends NextRequest { + auth: { + userId: string; + emailAccountId: string; + email: string; }; } @@ -108,28 +116,47 @@ async function authMiddleware( } const userId = session.user.id; - let userEmail = session.user.email; - // Check for X-Account-ID header - const accountId = req.headers.get("X-Account-ID"); + // Create a new request with auth info + const authReq = req.clone() as RequestWithAuth; + authReq.auth = { userId }; - // If account ID is provided, validate and get the email account ID - if (accountId) { - userEmail = await validateUserAccount(userId, accountId); + return authReq; +} + +async function emailAccountMiddleware( + req: NextRequest, +): Promise { + const authReq = await authMiddleware(req); + if (authReq instanceof Response) return authReq; + + const userId = authReq.auth.userId; + + // Check for X-Email-Account-ID header + const emailAccountId = req.headers.get(EMAIL_ACCOUNT_HEADER); + + if (!emailAccountId) { + return NextResponse.json( + { error: "Email account ID is required", isKnownError: true }, + { status: 403 }, + ); } - if (!userEmail) { + // If account ID is provided, validate and get the email account ID + const email = await getEmailAccount({ userId, emailAccountId }); + + if (!email) { return NextResponse.json( { error: "Invalid account ID", isKnownError: true }, { status: 403 }, ); } - // Create a new request with auth info - const authReq = req.clone() as RequestWithAuth; - authReq.auth = { userId, userEmail }; + // Create a new request with email account info + const emailAccountReq = req.clone() as RequestWithEmailAccount; + emailAccountReq.auth = { userId, emailAccountId, email }; - return authReq; + return emailAccountReq; } // Public middlewares that build on the common infrastructure @@ -141,6 +168,12 @@ export function withAuth(handler: NextHandler): NextHandler { return withMiddleware(handler, authMiddleware); } +export function withEmailAccount( + handler: NextHandler, +): NextHandler { + return withMiddleware(handler, emailAccountMiddleware); +} + function isErrorWithConfigAndHeaders( error: unknown, ): error is { config: { headers: unknown } } { diff --git a/apps/web/utils/redis/label.ts b/apps/web/utils/redis/label.ts index 8bad15f80..a764aa6a2 100644 --- a/apps/web/utils/redis/label.ts +++ b/apps/web/utils/redis/label.ts @@ -9,44 +9,56 @@ const redisLabelSchema = z.object({ }); export type RedisLabel = z.infer; -function getUserLabelsKey(email: string) { - return `labels:user:${email}`; +function getUserLabelsKey({ emailAccountId }: { emailAccountId: string }) { + return `labels:user:${emailAccountId}`; } // user labels -async function getUserLabels(options: { email: string }) { - const key = getUserLabelsKey(options.email); +async function getUserLabels({ emailAccountId }: { emailAccountId: string }) { + const key = getUserLabelsKey({ emailAccountId }); return redis.get(key); } export async function saveUserLabel(options: { - email: string; + emailAccountId: string; label: RedisLabel; }) { const existingLabels = await getUserLabels(options); const newLabels = [...(existingLabels ?? []), options.label]; - return saveUserLabels({ email: options.email, labels: newLabels }); + return saveUserLabels({ + emailAccountId: options.emailAccountId, + labels: newLabels, + }); } -export async function saveUserLabels(options: { - email: string; +export async function saveUserLabels({ + emailAccountId, + labels, +}: { + emailAccountId: string; labels: RedisLabel[]; }) { - const key = getUserLabelsKey(options.email); - return redis.set(key, options.labels); + const key = getUserLabelsKey({ emailAccountId }); + return redis.set(key, labels); } -export async function deleteUserLabels(options: { email: string }) { - const key = getUserLabelsKey(options.email); +export async function deleteUserLabels({ + emailAccountId, +}: { emailAccountId: string }) { + const key = getUserLabelsKey({ emailAccountId }); return redis.del(key); } // inbox zero labels -function getInboxZeroLabelsKey(email: string) { - return `labels:inboxzero:${email}`; +function getInboxZeroLabelsKey({ emailAccountId }: { emailAccountId: string }) { + return `labels:inboxzero:${emailAccountId}`; } -export async function deleteInboxZeroLabels(options: { email: string }) { - const key = getInboxZeroLabelsKey(options.email); +export async function deleteInboxZeroLabels({ + emailAccountId, +}: { + emailAccountId: string; +}) { + const key = getInboxZeroLabelsKey({ emailAccountId }); return redis.del(key); } diff --git a/apps/web/utils/redis/message-processing.ts b/apps/web/utils/redis/message-processing.ts index 94b2d9fea..2796abaa5 100644 --- a/apps/web/utils/redis/message-processing.ts +++ b/apps/web/utils/redis/message-processing.ts @@ -1,15 +1,24 @@ import { redis } from "@/utils/redis"; -function getProcessingKey(userEmail: string, messageId: string) { +function getProcessingKey({ + userEmail, + messageId, +}: { + userEmail: string; + messageId: string; +}) { return `processing-message:${userEmail}:${messageId}`; } -export async function markMessageAsProcessing(options: { +export async function markMessageAsProcessing({ + userEmail, + messageId, +}: { userEmail: string; messageId: string; }): Promise { const result = await redis.set( - getProcessingKey(options.userEmail, options.messageId), + getProcessingKey({ userEmail, messageId }), "true", { ex: 60 * 5, // 5 minutes diff --git a/apps/web/utils/redis/reply.ts b/apps/web/utils/redis/reply.ts index 380826245..60abcad24 100644 --- a/apps/web/utils/redis/reply.ts +++ b/apps/web/utils/redis/reply.ts @@ -1,35 +1,35 @@ import { redis } from "@/utils/redis"; function getReplyKey({ - email, + emailAccountId, messageId, }: { - email: string; + emailAccountId: string; messageId: string; }) { - return `reply:${email}:${messageId}`; + return `reply:${emailAccountId}:${messageId}`; } export async function getReply({ - email, + emailAccountId, messageId, }: { - email: string; + emailAccountId: string; messageId: string; }): Promise { - return redis.get(getReplyKey({ email, messageId })); + return redis.get(getReplyKey({ emailAccountId, messageId })); } export async function saveReply({ - email, + emailAccountId, messageId, reply, }: { - email: string; + emailAccountId: string; messageId: string; reply: string; }) { - return redis.set(getReplyKey({ email, messageId }), reply, { + return redis.set(getReplyKey({ emailAccountId, messageId }), reply, { ex: 60 * 60 * 24, // 1 day }); } diff --git a/apps/web/utils/reply-tracker/check-previous-emails.ts b/apps/web/utils/reply-tracker/check-previous-emails.ts index 038eeea71..99f536623 100644 --- a/apps/web/utils/reply-tracker/check-previous-emails.ts +++ b/apps/web/utils/reply-tracker/check-previous-emails.ts @@ -68,11 +68,19 @@ export async function processPreviousSentEmails( if (latestMessage.labelIds?.includes(GmailLabel.SENT)) { // outbound logger.info("Processing outbound reply", loggerOptions); - await handleOutboundReply(emailAccount, latestMessage, gmail); + await handleOutboundReply({ + emailAccount, + message: latestMessage, + gmail, + }); } else { // inbound logger.info("Processing inbound reply", loggerOptions); - await handleInboundReply(emailAccount, latestMessage, gmail); + await handleInboundReply({ + emailAccount, + message: latestMessage, + gmail, + }); } revalidatePath("/reply-zero"); diff --git a/apps/web/utils/reply-tracker/draft-tracking.ts b/apps/web/utils/reply-tracker/draft-tracking.ts index 0a6f9676f..0615f53b0 100644 --- a/apps/web/utils/reply-tracker/draft-tracking.ts +++ b/apps/web/utils/reply-tracker/draft-tracking.ts @@ -13,18 +13,17 @@ const logger = createScopedLogger("draft-tracking"); * Checks if a sent message originated from an AI draft and logs its similarity. */ export async function trackSentDraftStatus({ - user, + emailAccountId, message, gmail, }: { - user: Pick; + emailAccountId: string; message: ParsedMessage; gmail: gmail_v1.Gmail; }) { - const { id: userId, email: userEmail } = user; const { threadId, id: sentMessageId, textPlain: sentTextPlain } = message; - const loggerOptions = { userId, userEmail, threadId, sentMessageId }; + const loggerOptions = { threadId, sentMessageId }; logger.info( "Checking if sent message corresponds to an AI draft", @@ -33,7 +32,6 @@ export async function trackSentDraftStatus({ if (!sentMessageId) { logger.warn("Sent message missing ID, cannot track draft status", { - userId, threadId, }); return; @@ -43,7 +41,7 @@ export async function trackSentDraftStatus({ const executedAction = await prisma.executedAction.findFirst({ where: { executedRule: { - emailAccountId: user.email, + emailAccountId, threadId: threadId, }, type: ActionType.DRAFT_EMAIL, @@ -134,14 +132,14 @@ export async function trackSentDraftStatus({ */ export async function cleanupThreadAIDrafts({ threadId, - email, + emailAccountId, gmail, }: { threadId: string; - email: string; + emailAccountId: string; gmail: gmail_v1.Gmail; }) { - const loggerOptions = { email, threadId }; + const loggerOptions = { emailAccountId, threadId }; logger.info("Starting cleanup of old AI drafts for thread", loggerOptions); try { @@ -149,7 +147,7 @@ export async function cleanupThreadAIDrafts({ const potentialDraftsToClean = await prisma.executedAction.findMany({ where: { executedRule: { - emailAccountId: email, + emailAccountId, threadId: threadId, }, type: ActionType.DRAFT_EMAIL, diff --git a/apps/web/utils/reply-tracker/generate-draft.ts b/apps/web/utils/reply-tracker/generate-draft.ts index b1d4a2193..347b5dad4 100644 --- a/apps/web/utils/reply-tracker/generate-draft.ts +++ b/apps/web/utils/reply-tracker/generate-draft.ts @@ -19,26 +19,23 @@ import { getAccessTokenFromClient } from "@/utils/gmail/client"; const logger = createScopedLogger("generate-reply"); export async function generateDraft({ - userId, - userEmail, + emailAccountId, gmail, message, }: { - userId: string; - userEmail: string; + emailAccountId: string; gmail: gmail_v1.Gmail; message: ParsedMessage; }) { const logger = createScopedLogger("generate-reply").with({ - email: userEmail, - userId, + emailAccountId, messageId: message.id, threadId: message.threadId, }); logger.info("Generating draft"); - const emailAccount = await getEmailAccountWithAi({ emailAccountId: userId }); + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); if (!emailAccount) throw new Error("User not found"); // 1. Draft with AI @@ -119,7 +116,7 @@ async function generateDraftContent( if (!lastMessage) throw new Error("No message provided"); const reply = await getReply({ - email: emailAccount.email, + emailAccountId: emailAccount.id, messageId: lastMessage.id, }); @@ -193,7 +190,7 @@ async function generateDraftContent( if (typeof text === "string") { await saveReply({ - email: emailAccount.email, + emailAccountId: emailAccount.id, messageId: lastMessage.id, reply: text, }); diff --git a/apps/web/utils/reply-tracker/inbound.ts b/apps/web/utils/reply-tracker/inbound.ts index dcb973010..aa601a0b9 100644 --- a/apps/web/utils/reply-tracker/inbound.ts +++ b/apps/web/utils/reply-tracker/inbound.ts @@ -116,18 +116,22 @@ async function updateThreadTrackers({ } // Currently this is used when enabling reply tracking. Otherwise we use regular AI rule processing to handle inbound replies -export async function handleInboundReply( - user: EmailAccountWithAI, - message: ParsedMessage, - gmail: gmail_v1.Gmail, -) { +export async function handleInboundReply({ + emailAccount, + message, + gmail, +}: { + emailAccount: EmailAccountWithAI; + message: ParsedMessage; + gmail: gmail_v1.Gmail; +}) { // 1. Run rules check // 2. If the reply tracking rule is selected then mark as needs reply // We ignore the rest of the actions for this rule here as this could lead to double handling of emails for the user const replyTrackingRules = await prisma.rule.findMany({ where: { - emailAccountId: user.email, + emailAccountId: emailAccount.id, instructions: { not: null }, actions: { some: { @@ -146,12 +150,12 @@ export async function handleInboundReply( name: rule.name, instructions: rule.instructions || "", })), - user, + emailAccount, }); if (replyTrackingRules.some((rule) => rule.id === result.rule?.id)) { await coordinateReplyProcess({ - emailAccountId: user.email, + emailAccountId: emailAccount.id, threadId: message.threadId, messageId: message.id, sentAt: internalDateToDate(message.internalDate), diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 7ec91ad7f..bfc8d01b2 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -11,11 +11,15 @@ import { getReplyTrackingLabels } from "@/utils/reply-tracker/label"; import { labelMessage, removeThreadLabel } from "@/utils/gmail/label"; import { internalDateToDate } from "@/utils/date"; -export async function handleOutboundReply( - emailAccount: EmailAccountWithAI, - message: ParsedMessage, - gmail: gmail_v1.Gmail, -) { +export async function handleOutboundReply({ + emailAccount, + message, + gmail, +}: { + emailAccount: EmailAccountWithAI; + message: ParsedMessage; + gmail: gmail_v1.Gmail; +}) { const logger = createScopedLogger("reply-tracker/outbound").with({ email: emailAccount.email, messageId: message.id, @@ -156,13 +160,13 @@ async function createReplyTrackerOutbound({ async function resolveReplyTrackers( gmail: gmail_v1.Gmail, - email: string, + emailAccountId: string, threadId: string, needsReplyLabelId: string, ) { const updateDbPromise = prisma.threadTracker.updateMany({ where: { - emailAccountId: email, + emailAccountId, threadId, resolved: false, type: ThreadTrackerType.NEEDS_REPLY, diff --git a/apps/web/utils/rule/prompt-file.ts b/apps/web/utils/rule/prompt-file.ts index 71c6b976f..1c89a0b0b 100644 --- a/apps/web/utils/rule/prompt-file.ts +++ b/apps/web/utils/rule/prompt-file.ts @@ -6,65 +6,64 @@ import { generatePromptOnUpdateRule } from "@/utils/ai/rule/generate-prompt-on-u import prisma from "@/utils/prisma"; export async function updatePromptFileOnRuleCreated({ - email, + emailAccountId, rule, }: { - email: string; + emailAccountId: string; rule: RuleWithRelations; }) { const prompt = createPromptFromRule(rule); - await appendRulePrompt({ email, rulePrompt: prompt }); + await appendRulePrompt({ emailAccountId, rulePrompt: prompt }); } export async function updatePromptFileOnRuleUpdated({ - email, + emailAccountId, currentRule, updatedRule, }: { - email: string; + emailAccountId: string; currentRule: RuleWithRelations; updatedRule: RuleWithRelations; }) { const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { + id: true, email: true, userId: true, about: true, - aiModel: true, - aiProvider: true, - aiApiKey: true, rulesPrompt: true, + user: { select: { aiProvider: true, aiModel: true, aiApiKey: true } }, }, }); if (!emailAccount) return; const updatedPrompt = await generatePromptOnUpdateRule({ - user: emailAccount, + emailAccount, existingPrompt: emailAccount.rulesPrompt || "", currentRule, updatedRule, }); await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { rulesPrompt: updatedPrompt }, }); } export async function updateRuleInstructionsAndPromptFile({ - email, + emailAccountId, ruleId, instructions, currentRule, }: { - email: string; + emailAccountId: string; ruleId: string; instructions: string; currentRule: RuleWithRelations | null; }) { const updatedRule = await prisma.rule.update({ - where: { id: ruleId, emailAccountId: email }, + where: { id: ruleId, emailAccountId }, data: { instructions }, include: { actions: true, categoryFilters: true, group: true }, }); @@ -72,24 +71,24 @@ export async function updateRuleInstructionsAndPromptFile({ // update prompt file if (currentRule) { await updatePromptFileOnRuleUpdated({ - email, + emailAccountId, currentRule, updatedRule, }); } else { - await appendRulePrompt({ email, rulePrompt: instructions }); + await appendRulePrompt({ emailAccountId, rulePrompt: instructions }); } } async function appendRulePrompt({ - email, + emailAccountId, rulePrompt, }: { - email: string; + emailAccountId: string; rulePrompt: string; }) { const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { rulesPrompt: true }, }); @@ -98,7 +97,7 @@ async function appendRulePrompt({ const updatedPrompt = `${existingPrompt}\n\n* ${rulePrompt}.`.trim(); await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { rulesPrompt: updatedPrompt }, }); } diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index f50d87f15..2b9d8faf4 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -29,24 +29,24 @@ export function partialUpdateRule({ export async function safeCreateRule({ result, - email, + emailAccountId, categoryNames, systemType, }: { result: CreateOrUpdateRuleSchemaWithCategories; - email: string; + emailAccountId: string; categoryNames?: string[] | null; systemType?: SystemType | null; }) { const categoryIds = await getUserCategoriesForNames({ - email, + emailAccountId, names: categoryNames || [], }); try { const rule = await createRule({ result, - email, + emailAccountId, categoryIds, systemType, }); @@ -56,14 +56,14 @@ export async function safeCreateRule({ // if rule name already exists, create a new rule with a unique name const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, - email, + emailAccountId, categoryIds, }); return rule; } logger.error("Error creating rule", { - email, + emailAccountId, error: error instanceof Error ? { message: error.message, stack: error.stack, name: error.name } @@ -76,19 +76,19 @@ export async function safeCreateRule({ export async function safeUpdateRule({ ruleId, result, - email, + emailAccountId, categoryIds, }: { ruleId: string; result: CreateOrUpdateRuleSchemaWithCategories; - email: string; + emailAccountId: string; categoryIds?: string[] | null; }) { try { const rule = await updateRule({ ruleId, result, - emailAccountId: email, + emailAccountId, categoryIds, }); return { id: rule.id }; @@ -97,14 +97,14 @@ export async function safeUpdateRule({ // if rule name already exists, create a new rule with a unique name const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, - email, + emailAccountId, categoryIds, }); return { id: rule.id }; } logger.error("Error updating rule", { - email, + emailAccountId, error: error instanceof Error ? { message: error.message, stack: error.stack, name: error.name } @@ -117,12 +117,12 @@ export async function safeUpdateRule({ export async function createRule({ result, - email, + emailAccountId, categoryIds, systemType, }: { result: CreateOrUpdateRuleSchemaWithCategories; - email: string; + emailAccountId: string; categoryIds?: string[] | null; systemType?: SystemType | null; }) { @@ -131,7 +131,7 @@ export async function createRule({ return prisma.rule.create({ data: { name: result.name, - emailAccountId: email, + emailAccountId, systemType, actions: { createMany: { data: mappedActions } }, automate: shouldAutomate( @@ -204,19 +204,19 @@ async function updateRule({ } export async function deleteRule({ - email, + emailAccountId, ruleId, groupId, }: { - email: string; + emailAccountId: string; ruleId: string; groupId?: string | null; }) { return Promise.all([ - prisma.rule.delete({ where: { id: ruleId, emailAccountId: email } }), + prisma.rule.delete({ where: { id: ruleId, emailAccountId } }), // in the future, we can make this a cascade delete, but we need to change the schema for this to happen groupId - ? prisma.group.delete({ where: { id: groupId, emailAccountId: email } }) + ? prisma.group.delete({ where: { id: groupId, emailAccountId } }) : null, ]); } diff --git a/apps/web/utils/unsubscribe.ts b/apps/web/utils/unsubscribe.ts index 3c96e5286..1be572486 100644 --- a/apps/web/utils/unsubscribe.ts +++ b/apps/web/utils/unsubscribe.ts @@ -3,16 +3,16 @@ import prisma from "./prisma"; import { generateSecureToken } from "./api-key"; export async function createUnsubscribeToken({ - email, + emailAccountId, }: { - email: string; + emailAccountId: string; }) { const token = generateSecureToken(); await prisma.emailToken.create({ data: { token, - emailAccountId: email, + emailAccountId, expiresAt: addDays(new Date(), 30), }, }); diff --git a/apps/web/utils/user/get.ts b/apps/web/utils/user/get.ts index 8db6fd909..d51411a38 100644 --- a/apps/web/utils/user/get.ts +++ b/apps/web/utils/user/get.ts @@ -1,7 +1,7 @@ import prisma from "@/utils/prisma"; import type { EmailAccountWithAI } from "@/utils/llms/types"; -export async function getAiUser({ +export async function getEmailAccountWithAi({ emailAccountId, }: { emailAccountId: string }): Promise { return prisma.emailAccount.findUnique({ @@ -56,9 +56,11 @@ export async function getEmailAccountWithAiAndTokens({ }; } -export async function getWritingStyle(email: string) { +export async function getWritingStyle({ + emailAccountId, +}: { emailAccountId: string }) { const writingStyle = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { writingStyle: true }, }); From 6447aead72fa42c92d1a919dc557b2ea408214fd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 00:09:30 +0300 Subject: [PATCH 074/176] more fixes --- .cursor/rules/get-api-route.mdc | 3 +- apps/web/app/api/google/watch/controller.ts | 22 ++-- .../google/webhook/process-history-item.ts | 2 +- .../app/api/google/webhook/process-history.ts | 12 +- .../scripts/deleteTinybirdDataForFreeUsers.ts | 75 ------------ apps/web/utils/actions/permissions.ts | 36 ++++-- .../ai/assistant/process-user-request.ts | 114 ++++++++++++------ .../assistant/process-assistant-email.ts | 37 ++++-- apps/web/utils/auth.ts | 12 +- .../utils/categorize/senders/categorize.ts | 20 +-- apps/web/utils/gmail/client.ts | 53 ++++---- apps/web/utils/gmail/permissions.ts | 34 +++--- apps/web/utils/gmail/scopes.ts | 12 ++ apps/web/utils/redis/stats.ts | 41 ------- apps/web/utils/user/delete.ts | 41 ++++--- packages/tinybird/src/delete.ts | 11 -- 16 files changed, 232 insertions(+), 293 deletions(-) delete mode 100644 apps/web/scripts/deleteTinybirdDataForFreeUsers.ts create mode 100644 apps/web/utils/gmail/scopes.ts delete mode 100644 apps/web/utils/redis/stats.ts diff --git a/.cursor/rules/get-api-route.mdc b/.cursor/rules/get-api-route.mdc index 8e4bdfa0e..3c34a27a7 100644 --- a/.cursor/rules/get-api-route.mdc +++ b/.cursor/rules/get-api-route.mdc @@ -17,7 +17,8 @@ import { withAuth } from "@/utils/middleware"; export type GetExampleResponse = Awaited>; export const GET = withAuth(async () => { - const email = request.auth.userEmail; + const emailAccountId = request.auth.emailAccountId; + const result = getData({ email }); return NextResponse.json(result); diff --git a/apps/web/app/api/google/watch/controller.ts b/apps/web/app/api/google/watch/controller.ts index 73efacd5d..f86387da9 100644 --- a/apps/web/app/api/google/watch/controller.ts +++ b/apps/web/app/api/google/watch/controller.ts @@ -1,6 +1,6 @@ import type { gmail_v1 } from "@googleapis/gmail"; import prisma from "@/utils/prisma"; -import { getGmailClientFromAccount } from "@/utils/gmail/client"; +import { getGmailClient } from "@/utils/gmail/client"; import { captureException } from "@/utils/error"; import { createScopedLogger } from "@/utils/logger"; import { watchGmail, unwatchGmail } from "@/utils/gmail/watch"; @@ -33,29 +33,29 @@ async function unwatch(gmail: gmail_v1.Gmail) { } export async function unwatchEmails({ - email, - access_token, - refresh_token, + emailAccountId, + accessToken, + refreshToken, }: { - email: string; - access_token: string | null; - refresh_token: string | null; + emailAccountId: string; + accessToken?: string | null; + refreshToken?: string | null; }) { try { - const gmail = getGmailClientFromAccount({ access_token, refresh_token }); + const gmail = getGmailClient({ accessToken, refreshToken }); await unwatch(gmail); } catch (error) { if (error instanceof Error && error.message.includes("invalid_grant")) { - logger.warn("Error unwatching emails, invalid grant", { email }); + logger.warn("Error unwatching emails, invalid grant", { emailAccountId }); return; } - logger.error("Error unwatching emails", { email, error }); + logger.error("Error unwatching emails", { emailAccountId, error }); captureException(error); } await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { watchEmailsExpirationDate: null }, }); } diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index 3b954e8e0..be038caa9 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -98,8 +98,8 @@ export async function processHistoryItem( logger.info("Passing through assistant email.", loggerOptions); return processAssistantEmail({ message, + emailAccountId, userEmail, - userId: emailAccount.userId, gmail, }); } diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index c727efe3d..5e338d065 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -82,9 +82,9 @@ export async function processHistoryForUser( lemonSqueezyRenewsAt: emailAccount.user.premium?.lemonSqueezyRenewsAt, }); await unwatchEmails({ - email: emailAccount.email, - access_token: emailAccount.account?.access_token ?? null, - refresh_token: emailAccount.account?.refresh_token ?? null, + emailAccountId: emailAccount.id, + accessToken: emailAccount.account?.access_token, + refreshToken: emailAccount.account?.refresh_token, }); return NextResponse.json({ ok: true }); } @@ -101,9 +101,9 @@ export async function processHistoryForUser( if (!userHasAiAccess && !userHasColdEmailAccess) { logger.trace("Does not have hasAiOrColdEmailAccess", { email }); await unwatchEmails({ - email: emailAccount.email, - access_token: emailAccount.account?.access_token ?? null, - refresh_token: emailAccount.account?.refresh_token ?? null, + emailAccountId: emailAccount.id, + accessToken: emailAccount.account?.access_token, + refreshToken: emailAccount.account?.refresh_token, }); return NextResponse.json({ ok: true }); } diff --git a/apps/web/scripts/deleteTinybirdDataForFreeUsers.ts b/apps/web/scripts/deleteTinybirdDataForFreeUsers.ts deleted file mode 100644 index d9cffdca0..000000000 --- a/apps/web/scripts/deleteTinybirdDataForFreeUsers.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Run with: `npx tsx scripts/deleteTinybirdDataForFreeUsers.ts` -// This script deletes all Tinybird data for users who are on the free plan. - -import { PrismaClient } from "@prisma/client"; -import { deleteTinybirdEmails } from "@inboxzero/tinybird"; -import { sleep } from "@/utils/sleep"; - -const prisma = new PrismaClient(); - -const THIRTY_DAYS_AGO = new Date( - new Date().getTime() - 30 * 24 * 60 * 60 * 1000, -); - -async function main() { - const users = await prisma.user.findMany({ - where: { - AND: [ - { - OR: [ - { - premium: { - lemonSqueezyRenewsAt: null, - }, - }, - { - premium: { - lemonSqueezyRenewsAt: { lt: new Date() }, - }, - }, - ], - }, - { - OR: [ - { - lastLogin: { lt: THIRTY_DAYS_AGO }, - }, - { - lastLogin: null, - }, - ], - }, - ], - }, - select: { email: true }, - orderBy: { createdAt: "asc" }, - // skip: 0, - }); - console.log(`Deleting Tinybird data for ${users.length} users.`); - - for (let i = 0; i < users.length; i++) { - const user = users[i]; - console.log(`Deleting data for index ${i}. Email: ${user.email}`); - - if (!user.email) { - console.warn(`No email for user: ${user.email}`); - continue; - } - - try { - await deleteTinybirdEmails({ email: user.email }); - await sleep(4_000); - } catch (error: any) { - console.error(error); - console.error(Object.keys(error)); - - await sleep(10_000); - } - } - - console.log(`Completed deleting Tinybird data for ${users.length} users.`); -} - -main().finally(() => { - prisma.$disconnect(); -}); diff --git a/apps/web/utils/actions/permissions.ts b/apps/web/utils/actions/permissions.ts index f894b142a..9a90b53fa 100644 --- a/apps/web/utils/actions/permissions.ts +++ b/apps/web/utils/actions/permissions.ts @@ -6,20 +6,22 @@ import { handleGmailPermissionsCheck } from "@/utils/gmail/permissions"; import { createScopedLogger } from "@/utils/logger"; import { actionClient, adminActionClient } from "@/utils/actions/safe-action"; import { getTokens } from "@/utils/account"; +import prisma from "@/utils/prisma"; const logger = createScopedLogger("actions/permissions"); export const checkPermissionsAction = actionClient .metadata({ name: "checkPermissions" }) - .action(async ({ ctx: { email } }) => { + .action(async ({ ctx: { emailAccountId } }) => { try { - const tokens = await getTokens({ email }); - const token = await getGmailAccessToken(tokens); - if (!token.token) return { error: "No Gmail access token" }; + const tokens = await getTokens({ emailAccountId }); + const accessToken = await getGmailAccessToken(tokens); + + if (!accessToken.token) return { error: "No access token" }; const { hasAllPermissions, error } = await handleGmailPermissionsCheck({ - accessToken: token.token, - email, + accessToken: accessToken.token, + emailAccountId, }); if (error) return { error }; @@ -31,7 +33,7 @@ export const checkPermissionsAction = actionClient return { hasRefreshToken: true, hasAllPermissions }; } catch (error) { logger.error("Failed to check permissions", { - email, + emailAccountId, error, }); return { error: "Failed to check permissions" }; @@ -43,14 +45,26 @@ export const adminCheckPermissionsAction = adminActionClient .schema(z.object({ email: z.string().email() })) .action(async ({ parsedInput: { email } }) => { try { - const tokens = await getTokens({ email }); - if (!tokens?.accessToken) return { error: "No access token" }; - const token = await getGmailAccessToken(tokens); + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, + select: { + id: true, + account: { select: { access_token: true, refresh_token: true } }, + }, + }); + if (!emailAccount) return { error: "Email account not found" }; + const emailAccountId = emailAccount.id; + if (!emailAccount.account?.access_token) + return { error: "No access token" }; + const token = await getGmailAccessToken({ + accessToken: emailAccount.account.access_token ?? undefined, + refreshToken: emailAccount.account.refresh_token ?? undefined, + }); if (!token.token) return { error: "No Gmail access token" }; const { hasAllPermissions, error } = await handleGmailPermissionsCheck({ accessToken: token.token, - email, + emailAccountId, }); if (error) return { error }; return { hasAllPermissions }; diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 3a98fb69d..7e38ff935 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -9,7 +9,7 @@ import { type Rule, type User, } from "@prisma/client"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { RuleWithRelations } from "@/utils/ai/rule/create-prompt-from-rule"; import { isDefined, type ParsedMessage } from "@/utils/types"; import { @@ -39,7 +39,7 @@ import { getUserCategoriesForNames } from "@/utils/category.server"; const logger = createScopedLogger("ai-fix-rules"); export async function processUserRequest({ - user, + emailAccount, rules, originalEmail, messages, @@ -47,7 +47,7 @@ export async function processUserRequest({ categories, senderCategory, }: { - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; rules: RuleWithRelations[]; originalEmail: ParsedMessage | null; messages: { role: "assistant" | "user"; content: string }[]; @@ -55,7 +55,7 @@ export async function processUserRequest({ categories: Pick[] | null; senderCategory: string | null; }) { - posthogCaptureEvent(user.email, "AI Assistant Process Started", { + posthogCaptureEvent(emailAccount.email, "AI Assistant Process Started", { hasOriginalEmail: !!originalEmail, hasMatchedRule: !!matchedRule, }); @@ -119,9 +119,9 @@ ${matchedRule ? ruleToXML(matchedRule) : "No rule matched"} ${!matchedRule ? userRules : ""} ${ - user.about + emailAccount.about ? ` - ${user.about} + ${emailAccount.about} ` : "" } @@ -160,8 +160,9 @@ ${senderCategory || "No category"} const updatedRules = new Map(); const loggerOptions = { - userId: user.userId, - email: user.email, + emailAccountId: emailAccount.id, + userId: emailAccount.userId, + email: emailAccount.email, messageId: originalEmail?.id, threadId: originalEmail?.threadId, }; @@ -198,7 +199,7 @@ ${senderCategory || "No category"} } const result = await chatCompletionTools({ - userAi: user, + userAi: emailAccount.user, messages: allMessages, tools: { update_conditional_operator: tool({ @@ -214,7 +215,10 @@ ${senderCategory || "No category"} ruleName, conditionalOperator, }); - trackToolCall("update_conditional_operator", user.email); + trackToolCall({ + tool: "update_conditional_operator", + email: emailAccount.email, + }); return updateRule(ruleName, { conditionalOperator }); }, @@ -227,7 +231,10 @@ ${senderCategory || "No category"} }), execute: async ({ ruleName, aiInstructions }) => { logger.info("Edit AI Instructions", { ruleName, aiInstructions }); - trackToolCall("update_ai_instructions", user.email); + trackToolCall({ + tool: "update_ai_instructions", + email: emailAccount.email, + }); return updateRule(ruleName, { instructions: aiInstructions }); }, @@ -240,7 +247,10 @@ ${senderCategory || "No category"} }), execute: async ({ ruleName, staticConditions }) => { logger.info("Edit Static Conditions", { ruleName, staticConditions }); - trackToolCall("update_static_conditions", user.email); + trackToolCall({ + tool: "update_static_conditions", + email: emailAccount.email, + }); return updateRule(ruleName, { from: staticConditions?.from, @@ -325,7 +335,10 @@ ${senderCategory || "No category"} }), execute: async ({ type, value }) => { logger.info("Remove Pattern", { type, value }); - trackToolCall("remove_pattern", user.email); + trackToolCall({ + tool: "remove_pattern", + email: emailAccount.email, + }); const groupItemType = getPatternType(type); @@ -354,7 +367,7 @@ ${senderCategory || "No category"} try { await deleteGroupItem({ id: groupItem.id, - email: user.email, + emailAccountId: emailAccount.id, }); } catch (error) { const message = @@ -381,12 +394,12 @@ ${senderCategory || "No category"} : {}), ...(categories ? { - update_sender_category: getUpdateCategoryTool( - user.userId, + update_sender_category: getUpdateCategoryTool({ + emailAccountId: emailAccount.id, + userEmail: emailAccount.email, categories, loggerOptions, - user.email, - ), + }), add_categories: tool({ description: "Add categories to a rule", parameters: z.object({ @@ -400,7 +413,10 @@ ${senderCategory || "No category"} execute: async (options) => { try { logger.info("Add Rule Categories", options); - trackToolCall("add_categories", user.email); + trackToolCall({ + tool: "add_categories", + email: emailAccount.email, + }); const { ruleName } = options; @@ -458,7 +474,10 @@ ${senderCategory || "No category"} execute: async (options) => { try { logger.info("Remove Rule Categories", options); - trackToolCall("remove_categories", user.email); + trackToolCall({ + tool: "remove_categories", + email: emailAccount.email, + }); const { ruleName } = options; @@ -514,20 +533,23 @@ ${senderCategory || "No category"} : createRuleSchema, execute: async ({ name, condition, actions }) => { logger.info("Create Rule", { name, condition, actions }); - trackToolCall("create_rule", user.email); + trackToolCall({ + tool: "create_rule", + email: emailAccount.email, + }); const conditions = condition as CreateRuleSchemaWithCategories["condition"]; try { const categoryIds = await getUserCategoriesForNames({ - email: user.email, + emailAccountId: emailAccount.id, names: conditions.categories?.categoryFilters || [], }); const rule = await createRule({ result: { name, condition, actions }, - email: user.email, + emailAccountId: emailAccount.id, categoryIds, }); @@ -566,7 +588,10 @@ ${senderCategory || "No category"} description: "List all existing rules for the user", parameters: z.object({}), execute: async () => { - trackToolCall("list_rules", user.email); + trackToolCall({ + tool: "list_rules", + email: emailAccount.email, + }); return userRules; }, }), @@ -580,7 +605,7 @@ ${senderCategory || "No category"} }, maxSteps: 5, label: "Fix Rule", - userEmail: user.email, + userEmail: emailAccount.email, }); const toolCalls = result.steps.flatMap((step) => step.toolCalls); @@ -591,7 +616,10 @@ ${senderCategory || "No category"} // Update prompt file for newly created rules for (const rule of createdRules.values()) { - await updatePromptFileOnRuleCreated({ email: user.email, rule }); + await updatePromptFileOnRuleCreated({ + emailAccountId: emailAccount.id, + rule, + }); } // Update prompt file for modified rules @@ -611,13 +639,13 @@ ${senderCategory || "No category"} } await updatePromptFileOnRuleUpdated({ - email: user.email, + emailAccountId: emailAccount.id, currentRule: originalRule, updatedRule: updatedRule, }); } - posthogCaptureEvent(user.email, "AI Assistant Process Completed", { + posthogCaptureEvent(emailAccount.email, "AI Assistant Process Completed", { toolCallCount: result.steps.length, rulesCreated: createdRules.size, rulesUpdated: updatedRules.size, @@ -626,17 +654,22 @@ ${senderCategory || "No category"} return result; } -const getUpdateCategoryTool = ( - userId: string, - categories: Pick[], +const getUpdateCategoryTool = ({ + emailAccountId, + categories, + loggerOptions, + userEmail, +}: { + emailAccountId: string; + categories: Pick[]; loggerOptions: { userId: string; email: string | null; messageId?: string | null; threadId?: string | null; - }, - userEmail: string, -) => + }; + userEmail: string; +}) => tool({ description: "Update the category of a sender", parameters: z.object({ @@ -650,10 +683,13 @@ const getUpdateCategoryTool = ( }), execute: async ({ sender, category }) => { logger.info("Update Category", { sender, category }); - trackToolCall("update_sender_category", userEmail); + trackToolCall({ + tool: "update_sender_category", + email: userEmail, + }); const existingSender = await findSenderByEmail({ - emailAccountId: userEmail, + emailAccountId, email: sender, }); @@ -669,7 +705,7 @@ const getUpdateCategoryTool = ( try { await updateCategoryForSender({ - userEmail, + emailAccountId, sender: existingSender?.email || sender, categoryId: cat.id, }); @@ -754,6 +790,6 @@ function getPatternType(type: string) { if (type === "subject") return GroupItemType.SUBJECT; } -async function trackToolCall(tool: string, userEmail: string) { - return posthogCaptureEvent(userEmail, "AI Assistant Tool Call", { tool }); +async function trackToolCall({ tool, email }: { tool: string; email: string }) { + return posthogCaptureEvent(email, "AI Assistant Tool Call", { tool }); } diff --git a/apps/web/utils/assistant/process-assistant-email.ts b/apps/web/utils/assistant/process-assistant-email.ts index b4799dfb4..57c23508e 100644 --- a/apps/web/utils/assistant/process-assistant-email.ts +++ b/apps/web/utils/assistant/process-assistant-email.ts @@ -15,22 +15,22 @@ import { internalDateToDate } from "@/utils/date"; const logger = createScopedLogger("process-assistant-email"); type ProcessAssistantEmailArgs = { + emailAccountId: string; userEmail: string; - userId: string; message: ParsedMessage; gmail: gmail_v1.Gmail; }; export async function processAssistantEmail({ + emailAccountId, userEmail, - userId, message, gmail, }: ProcessAssistantEmailArgs) { return withProcessingLabels(message.id, gmail, () => processAssistantEmailInternal({ + emailAccountId, userEmail, - userId, message, gmail, }), @@ -38,12 +38,12 @@ export async function processAssistantEmail({ } async function processAssistantEmailInternal({ + emailAccountId, userEmail, - userId, message, gmail, }: ProcessAssistantEmailArgs) { - if (!verifyUserSentEmail(message, userEmail)) { + if (!verifyUserSentEmail({ message, userEmail })) { logger.error("Unauthorized assistant access attempt", { email: userEmail, from: message.headers.from, @@ -53,7 +53,7 @@ async function processAssistantEmailInternal({ } const loggerOptions = { - email: userEmail, + emailAccountId, threadId: message.threadId, messageId: message.id, }; @@ -102,12 +102,17 @@ async function processAssistantEmailInternal({ prisma.emailAccount.findUnique({ where: { email: userEmail }, select: { + id: true, userId: true, email: true, about: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, + user: { + select: { + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }, rules: { include: { actions: true, @@ -134,7 +139,7 @@ async function processAssistantEmailInternal({ ? prisma.executedRule.findUnique({ where: { unique_emailAccount_thread_message: { - emailAccountId: userEmail, + emailAccountId, threadId: originalMessage.threadId, messageId: originalMessage.id, }, @@ -155,7 +160,7 @@ async function processAssistantEmailInternal({ where: { email_emailAccountId: { email: extractEmailAddress(originalMessage.headers.from), - emailAccountId: userEmail, + emailAccountId, }, }, select: { @@ -209,7 +214,7 @@ async function processAssistantEmailInternal({ } const result = await processUserRequest({ - user: emailAccount, + emailAccount, rules: emailAccount.rules, originalEmail: originalMessage, messages, @@ -231,7 +236,13 @@ async function processAssistantEmailInternal({ } } -function verifyUserSentEmail(message: ParsedMessage, userEmail: string) { +function verifyUserSentEmail({ + message, + userEmail, +}: { + message: ParsedMessage; + userEmail: string; +}) { return ( extractEmailAddress(message.headers.from).toLowerCase() === userEmail.toLowerCase() diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 7990fc98f..7b4cee147 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -10,20 +10,10 @@ import prisma from "@/utils/prisma"; import { env } from "@/env"; import { captureException } from "@/utils/error"; import { createScopedLogger } from "@/utils/logger"; +import { SCOPES } from "@/utils/gmail/scopes"; const logger = createScopedLogger("auth"); -export const SCOPES = [ - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/gmail.settings.basic", - ...(env.NEXT_PUBLIC_CONTACTS_ENABLED - ? ["https://www.googleapis.com/auth/contacts"] - : []), -]; - export const getAuthOptions: (options?: { consent: boolean; }) => NextAuthConfig = (options) => ({ diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index e14341aab..fdb526409 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -46,7 +46,7 @@ export async function categorizeSender( sender: senderAddress, categories, categoryName: aiResult.category, - userEmail: emailAccount.email, + emailAccountId: emailAccount.id, }); return { categoryId: newsletter.categoryId }; @@ -61,12 +61,12 @@ export async function categorizeSender( } export async function updateSenderCategory({ - userEmail, + emailAccountId, sender, categories, categoryName, }: { - userEmail: string; + emailAccountId: string; sender: string; categories: Pick[]; categoryName: string; @@ -79,7 +79,7 @@ export async function updateSenderCategory({ newCategory = await prisma.category.create({ data: { name: categoryName, - emailAccountId: userEmail, + emailAccountId, // color: getRandomColor(), }, }); @@ -89,12 +89,12 @@ export async function updateSenderCategory({ // save category const newsletter = await prisma.newsletter.upsert({ where: { - email_emailAccountId: { email: sender, emailAccountId: userEmail }, + email_emailAccountId: { email: sender, emailAccountId }, }, update: { categoryId: category.id }, create: { email: sender, - emailAccountId: userEmail, + emailAccountId, categoryId: category.id, }, }); @@ -106,21 +106,21 @@ export async function updateSenderCategory({ } export async function updateCategoryForSender({ - userEmail, + emailAccountId, sender, categoryId, }: { - userEmail: string; + emailAccountId: string; sender: string; categoryId: string; }) { const email = extractEmailAddress(sender); await prisma.newsletter.upsert({ - where: { email_emailAccountId: { email, emailAccountId: userEmail } }, + where: { email_emailAccountId: { email, emailAccountId } }, update: { categoryId }, create: { email, - emailAccountId: userEmail, + emailAccountId, categoryId, }, }); diff --git a/apps/web/utils/gmail/client.ts b/apps/web/utils/gmail/client.ts index d39dd9457..0727b8fcc 100644 --- a/apps/web/utils/gmail/client.ts +++ b/apps/web/utils/gmail/client.ts @@ -3,54 +3,45 @@ import { people } from "@googleapis/people"; import { saveRefreshToken } from "@/utils/auth"; import { env } from "@/env"; import { createScopedLogger } from "@/utils/logger"; -import type { Account } from "@prisma/client"; +import { SCOPES } from "@/utils/gmail/scopes"; const logger = createScopedLogger("gmail/client"); -type ClientOptions = { - accessToken?: string; - refreshToken?: string; +type AuthOptions = { + accessToken?: string | null; + refreshToken?: string | null; + expiryDate?: number | null; }; -const getClient = (session: ClientOptions) => { +const getAuth = ({ accessToken, refreshToken, expiryDate }: AuthOptions) => { const googleAuth = new auth.OAuth2({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }); - // not passing refresh_token when next-auth handles it googleAuth.setCredentials({ - access_token: session.accessToken, - refresh_token: session.refreshToken, + access_token: accessToken, + refresh_token: refreshToken, + expiry_date: expiryDate, + scope: SCOPES.join(" "), }); return googleAuth; }; -export const getGmailClient = (session: ClientOptions) => { - const auth = getClient(session); - const g = gmail({ version: "v1", auth }); - - return g; -}; - -export const getGmailClientFromAccount = ( - account: Pick, -) => { - return getGmailClient({ - accessToken: account.access_token ?? undefined, - refreshToken: account.refresh_token ?? undefined, - }); +export const getGmailClient = (options: AuthOptions) => { + const auth = getAuth(options); + return gmail({ version: "v1", auth }); }; -export const getContactsClient = (session: ClientOptions) => { - const auth = getClient(session); +export const getContactsClient = (session: AuthOptions) => { + const auth = getAuth(session); const contacts = people({ version: "v1", auth }); return contacts; }; -export const getGmailAccessToken = (session: ClientOptions) => { - const auth = getClient(session); +export const getGmailAccessToken = async (options: AuthOptions) => { + const auth = getAuth(options); return auth.getAccessToken(); }; @@ -62,20 +53,20 @@ export const getAccessTokenFromClient = (client: gmail_v1.Gmail): string => { }; export const getGmailClientWithRefresh = async ( - session: ClientOptions & { refreshToken: string; expiryDate?: number | null }, + options: AuthOptions & { refreshToken: string; expiryDate?: number | null }, providerAccountId: string, ): Promise => { - const auth = getClient(session); + const auth = getAuth(options); const g = gmail({ version: "v1", auth }); - if (session.expiryDate && session.expiryDate > Date.now()) return g; + if (options.expiryDate && options.expiryDate > Date.now()) return g; // may throw `invalid_grant` error try { const tokens = await auth.refreshAccessToken(); const newAccessToken = tokens.credentials.access_token; - if (newAccessToken !== session.accessToken) { + if (newAccessToken !== options.accessToken) { await saveRefreshToken( { access_token: newAccessToken ?? undefined, @@ -84,7 +75,7 @@ export const getGmailClientWithRefresh = async ( : undefined, }, { - refresh_token: session.refreshToken, + refresh_token: options.refreshToken, providerAccountId, }, ); diff --git a/apps/web/utils/gmail/permissions.ts b/apps/web/utils/gmail/permissions.ts index 8a5e147f8..2b07f5452 100644 --- a/apps/web/utils/gmail/permissions.ts +++ b/apps/web/utils/gmail/permissions.ts @@ -1,4 +1,4 @@ -import { SCOPES } from "@/utils/auth"; +import { SCOPES } from "@/utils/gmail/scopes"; import { createScopedLogger } from "@/utils/logger"; import prisma from "@/utils/prisma"; @@ -7,17 +7,17 @@ const logger = createScopedLogger("Gmail Permissions"); // TODO: this can also error on network error async function checkGmailPermissions({ accessToken, - email, + emailAccountId, }: { accessToken: string; - email: string; + emailAccountId: string; }): Promise<{ hasAllPermissions: boolean; missingScopes: string[]; error?: string; }> { if (!accessToken) { - logger.error("No access token available", { email }); + logger.error("No access token available", { emailAccountId }); return { hasAllPermissions: false, missingScopes: SCOPES, @@ -34,7 +34,7 @@ async function checkGmailPermissions({ if (data.error) { logger.error("Invalid token or Google API error", { - email, + emailAccountId, error: data.error, }); return { @@ -52,11 +52,14 @@ async function checkGmailPermissions({ const hasAllPermissions = missingScopes.length === 0; if (!hasAllPermissions) - logger.info("Missing Gmail permissions", { email, missingScopes }); + logger.info("Missing Gmail permissions", { + emailAccountId, + missingScopes, + }); return { hasAllPermissions, missingScopes }; } catch (error) { - logger.error("Error checking Gmail permissions", { email, error }); + logger.error("Error checking Gmail permissions", { emailAccountId, error }); return { hasAllPermissions: false, missingScopes: SCOPES, // Assume all scopes are missing if we can't check @@ -67,22 +70,25 @@ async function checkGmailPermissions({ export async function handleGmailPermissionsCheck({ accessToken, - email, + emailAccountId, }: { accessToken: string; - email: string; + emailAccountId: string; }) { const { hasAllPermissions, error, missingScopes } = - await checkGmailPermissions({ accessToken, email }); + await checkGmailPermissions({ accessToken, emailAccountId }); if (error === "invalid_token") { - logger.info("Cleaning up invalid Gmail tokens", { email }); + logger.info("Cleaning up invalid Gmail tokens", { emailAccountId }); // Clean up invalid tokens - const user = await prisma.user.findUnique({ where: { email } }); - if (!user) return { hasAllPermissions: false, error: "User not found" }; + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + }); + if (!emailAccount) + return { hasAllPermissions: false, error: "Email account not found" }; await prisma.account.update({ - where: { provider: "google", userId: user.id }, + where: { id: emailAccount.accountId }, data: { access_token: null, refresh_token: null, diff --git a/apps/web/utils/gmail/scopes.ts b/apps/web/utils/gmail/scopes.ts new file mode 100644 index 000000000..91d7e63f3 --- /dev/null +++ b/apps/web/utils/gmail/scopes.ts @@ -0,0 +1,12 @@ +import { env } from "@/env"; + +export const SCOPES = [ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.settings.basic", + ...(env.NEXT_PUBLIC_CONTACTS_ENABLED + ? ["https://www.googleapis.com/auth/contacts"] + : []), +]; diff --git a/apps/web/utils/redis/stats.ts b/apps/web/utils/redis/stats.ts deleted file mode 100644 index 2e8e2e5df..000000000 --- a/apps/web/utils/redis/stats.ts +++ /dev/null @@ -1,41 +0,0 @@ -import "server-only"; -import { redis } from "@/utils/redis"; - -export type RedisStats = Record; // { [day] : count } - -function getStatsKey(email: string) { - return `stats:${email}`; -} - -export async function getAllStats(options: { email: string }) { - const key = getStatsKey(options.email); - return redis.hgetall(key); -} - -export async function getDayStat(options: { email: string; day: string }) { - const key = getStatsKey(options.email); - return redis.hget(key, options.day); -} - -export async function saveDayStat(options: { - email: string; - day: string; - count: number; -}) { - return redis.hmset(getStatsKey(options.email), { - [options.day]: options.count, - }); -} - -export async function saveUserStats(options: { - email: string; - stats: RedisStats; -}) { - const key = getStatsKey(options.email); - return redis.set(key, options.stats); -} - -export async function deleteUserStats(options: { email: string }) { - const key = getStatsKey(options.email); - return redis.del(key); -} diff --git a/apps/web/utils/user/delete.ts b/apps/web/utils/user/delete.ts index 0fd3c7358..965d2d098 100644 --- a/apps/web/utils/user/delete.ts +++ b/apps/web/utils/user/delete.ts @@ -2,8 +2,6 @@ import { deleteContact as deleteLoopsContact } from "@inboxzero/loops"; import { deleteContact as deleteResendContact } from "@inboxzero/resend"; import prisma from "@/utils/prisma"; import { deleteInboxZeroLabels, deleteUserLabels } from "@/utils/redis/label"; -import { deleteUserStats } from "@/utils/redis/stats"; -import { deleteTinybirdEmails } from "@inboxzero/tinybird"; import { deleteTinybirdAiCalls } from "@inboxzero/tinybird-ai-analytics"; import { deletePosthogUser } from "@/utils/posthog"; import { captureException } from "@/utils/error"; @@ -20,13 +18,18 @@ export async function deleteUser({ }) { const accounts = await prisma.account.findMany({ where: { userId }, - select: { access_token: true, emailAccount: { select: { email: true } } }, + select: { + access_token: true, + emailAccount: { select: { id: true, email: true } }, + }, }); const resourcesPromise = accounts.map((account) => { if (!account.emailAccount) return Promise.resolve(); return deleteResources({ + emailAccountId: account.emailAccount.id, email: account.emailAccount.email, + userId, accessToken: account.access_token, }); }); @@ -71,25 +74,27 @@ export async function deleteUser({ } async function deleteResources({ + emailAccountId, email, + userId, accessToken, }: { + emailAccountId: string; email: string; + userId: string; accessToken: string | null; }) { const resourcesPromise = Promise.allSettled([ - deleteUserLabels({ email }), - deleteInboxZeroLabels({ email }), - deleteUserStats({ email }), - deleteTinybirdEmails({ email }), + deleteUserLabels({ emailAccountId }), + deleteInboxZeroLabels({ emailAccountId }), + deleteLoopsContact(emailAccountId), deletePosthogUser({ email }), - deleteLoopsContact(email), deleteResendContact({ email }), accessToken ? unwatchEmails({ - email, - access_token: accessToken, - refresh_token: null, + emailAccountId, + accessToken, + refreshToken: null, }) : Promise.resolve(), ]); @@ -98,15 +103,15 @@ async function deleteResources({ // First delete ExecutedRules and their associated ExecutedActions in batches // If we try do this in one go for a user with a lot of executed rules, this will fail logger.info("Deleting ExecutedRules in batches"); - await deleteExecutedRulesInBatches({ email }); + await deleteExecutedRulesInBatches({ emailAccountId }); logger.info("Deleting user"); - await prisma.user.delete({ where: { email } }); + await prisma.user.delete({ where: { id: userId } }); } catch (error) { logger.error("Error during database user deletion process", { error, - email, + emailAccountId, }); - captureException(error, { extra: { email } }, email); + captureException(error, { extra: { emailAccountId } }, emailAccountId); throw error; } @@ -117,10 +122,10 @@ async function deleteResources({ * Delete ExecutedRules and their associated ExecutedActions in batches */ async function deleteExecutedRulesInBatches({ - email, + emailAccountId, batchSize = 100, }: { - email: string; + emailAccountId: string; batchSize?: number; }) { let deletedTotal = 0; @@ -128,7 +133,7 @@ async function deleteExecutedRulesInBatches({ while (true) { // 1. Get a batch of ExecutedRule IDs const executedRules = await prisma.executedRule.findMany({ - where: { emailAccountId: email }, + where: { emailAccountId }, select: { id: true }, take: batchSize, }); diff --git a/packages/tinybird/src/delete.ts b/packages/tinybird/src/delete.ts index a2ce209a1..02054b925 100644 --- a/packages/tinybird/src/delete.ts +++ b/packages/tinybird/src/delete.ts @@ -31,17 +31,6 @@ async function deleteFromDatasource( return await res.json(); } -export async function deleteTinybirdEmails(options: { - email: string; -}): Promise { - if (!TINYBIRD_TOKEN) return; - await deleteFromDatasourceWithRetry("email", `ownerEmail='${options.email}'`); - await deleteFromDatasourceWithRetry( - "last_and_oldest_emails_mv", - `ownerEmail='${options.email}'`, - ); -} - // Tinybird only allows 1 delete at a time async function deleteFromDatasourceWithRetry( datasource: string, From 661d906346a3d8c07f70a336edb5098ec1770957 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:45:57 +0300 Subject: [PATCH 075/176] adjustments to gmail client --- .../simple/completed/page.tsx | 18 +++---- apps/web/app/api/google/contacts/route.ts | 10 ++-- .../app/api/google/messages/batch/route.ts | 13 +++-- .../web/app/api/google/threads/batch/route.ts | 15 +++--- apps/web/app/api/google/threads/route.ts | 16 +++--- apps/web/app/api/google/watch/all/route.ts | 9 ++-- .../app/api/google/webhook/process-history.ts | 9 +--- .../categorize/senders/batch/handle-batch.ts | 3 +- .../app/api/user/stats/tinybird/load/route.ts | 12 ++--- apps/web/utils/account.ts | 20 +++++-- apps/web/utils/actions/permissions.ts | 27 +++++----- apps/web/utils/api-auth.test.ts | 4 +- apps/web/utils/api-auth.ts | 3 +- apps/web/utils/auth.ts | 15 +++--- apps/web/utils/gmail/client.ts | 53 +++++++++++++++---- 15 files changed, 121 insertions(+), 106 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx b/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx index 8cafde9aa..14c6c7247 100644 --- a/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/completed/page.tsx @@ -8,8 +8,7 @@ import { getGmailBasicSearchUrl } from "@/utils/url"; import { OpenMultipleGmailButton } from "@/app/(app)/[emailAccountId]/simple/completed/OpenMultipleGmailButton"; import { SimpleProgressCompleted } from "@/app/(app)/[emailAccountId]/simple/SimpleProgress"; import { ShareOnTwitterButton } from "@/app/(app)/[emailAccountId]/simple/completed/ShareOnTwitterButton"; -import { getTokens } from "@/utils/account"; -import { getGmailClient, getGmailAccessToken } from "@/utils/gmail/client"; +import { getGmailAndAccessTokenForEmail } from "@/utils/account"; import prisma from "@/utils/prisma"; export default async function SimpleCompletedPage(props: { @@ -17,15 +16,14 @@ export default async function SimpleCompletedPage(props: { }) { const params = await props.params; - const tokens = await getTokens({ email: params.account }); - - const gmail = getGmailClient(tokens); - const token = await getGmailAccessToken(tokens); + const { gmail, accessToken } = await getGmailAndAccessTokenForEmail({ + emailAccountId: params.emailAccountId, + }); - if (!token.token) throw new Error("Account not found"); + if (!accessToken) throw new Error("Account not found"); const emailAccount = await prisma.emailAccount.findUnique({ - where: { email: params.account }, + where: { id: params.emailAccountId }, select: { email: true }, }); @@ -36,8 +34,8 @@ export default async function SimpleCompletedPage(props: { const { threads } = await getThreads({ query: { q: "newer_than:1d in:inbox" }, gmail, - accessToken: token.token, - email: emailAccount.email, + accessToken, + emailAccountId: params.emailAccountId, }); return ( diff --git a/apps/web/app/api/google/contacts/route.ts b/apps/web/app/api/google/contacts/route.ts index 70c4f2bd4..c044eddec 100644 --- a/apps/web/app/api/google/contacts/route.ts +++ b/apps/web/app/api/google/contacts/route.ts @@ -1,7 +1,7 @@ import type { people_v1 } from "@googleapis/people"; import { z } from "zod"; import { NextResponse } from "next/server"; -import { withAuth } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { getContactsClient } from "@/utils/gmail/client"; import { searchContacts } from "@/utils/gmail/contact"; import { env } from "@/env"; @@ -16,13 +16,13 @@ async function getContacts(client: people_v1.People, query: string) { return { result }; } -export const GET = withAuth(async (request) => { +export const GET = withEmailAccount(async (request) => { if (!env.NEXT_PUBLIC_CONTACTS_ENABLED) return NextResponse.json({ error: "Contacts API not enabled" }); - const tokens = await getTokens({ email: request.auth.userEmail }); - if (!tokens) return NextResponse.json({ error: "Account not found" }); - + const tokens = await getTokens({ + emailAccountId: request.auth.emailAccountId, + }); const client = getContactsClient(tokens); const { searchParams } = new URL(request.url); diff --git a/apps/web/app/api/google/messages/batch/route.ts b/apps/web/app/api/google/messages/batch/route.ts index 125ed0b9b..8f595a3fb 100644 --- a/apps/web/app/api/google/messages/batch/route.ts +++ b/apps/web/app/api/google/messages/batch/route.ts @@ -1,11 +1,10 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import { withEmailAccount } from "@/utils/middleware"; -import { getGmailAccessToken } from "@/utils/gmail/client"; import { uniq } from "lodash"; import { getMessagesBatch } from "@/utils/gmail/message"; import { parseReply } from "@/utils/mail"; -import { getTokens } from "@/utils/account"; +import { getGmailAndAccessTokenForEmail } from "@/utils/account"; const messagesBatchQuery = z.object({ ids: z @@ -22,11 +21,11 @@ export type MessagesBatchResponse = { export const GET = withEmailAccount(async (request) => { const emailAccountId = request.auth.emailAccountId; - const tokens = await getTokens({ emailAccountId }); - const accessToken = await getGmailAccessToken(tokens); + const { accessToken } = await getGmailAndAccessTokenForEmail({ + emailAccountId, + }); - if (!accessToken.token) - return NextResponse.json({ error: "Invalid access token" }); + if (!accessToken) return NextResponse.json({ error: "Invalid access token" }); const { searchParams } = new URL(request.url); const ids = searchParams.get("ids"); @@ -36,7 +35,7 @@ export const GET = withEmailAccount(async (request) => { parseReplies: parseReplies === "true", }); - const messages = await getMessagesBatch(query.ids, accessToken.token); + const messages = await getMessagesBatch(query.ids, accessToken); const result = query.parseReplies ? messages.map((message) => ({ diff --git a/apps/web/app/api/google/threads/batch/route.ts b/apps/web/app/api/google/threads/batch/route.ts index 3055244af..415ec4304 100644 --- a/apps/web/app/api/google/threads/batch/route.ts +++ b/apps/web/app/api/google/threads/batch/route.ts @@ -2,8 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { withEmailAccount } from "@/utils/middleware"; import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; -import { getTokens } from "@/utils/account"; -import { getGmailAccessToken } from "@/utils/gmail/client"; +import { getGmailAndAccessTokenForEmail } from "@/utils/account"; const requestSchema = z.object({ threadIds: z.array(z.string()), @@ -27,20 +26,20 @@ export const GET = withEmailAccount(async (request) => { return NextResponse.json({ threads: [] } satisfies ThreadsBatchResponse); } - const tokens = await getTokens({ emailAccountId }); - if (!tokens) return NextResponse.json({ error: "Account not found" }); - - const token = await getGmailAccessToken(tokens); + const { accessToken } = await getGmailAndAccessTokenForEmail({ + emailAccountId, + }); - if (!token.token) + if (!accessToken) { return NextResponse.json( { error: "Missing access token" }, { status: 401 }, ); + } const response = await getThreadsBatchAndParse( threadIds, - token.token, + accessToken, includeDrafts, ); diff --git a/apps/web/app/api/google/threads/route.ts b/apps/web/app/api/google/threads/route.ts index c286c18cc..4033aff6f 100644 --- a/apps/web/app/api/google/threads/route.ts +++ b/apps/web/app/api/google/threads/route.ts @@ -2,9 +2,7 @@ import { NextResponse } from "next/server"; import { withEmailAccount } from "@/utils/middleware"; import { getThreads } from "@/app/api/google/threads/controller"; import { threadsQuery } from "@/app/api/google/threads/validation"; -import { getGmailAccessToken } from "@/utils/gmail/client"; -import { getTokens } from "@/utils/account"; -import { getGmailClient } from "@/utils/gmail/client"; +import { getGmailAndAccessTokenForEmail } from "@/utils/account"; export const dynamic = "force-dynamic"; @@ -29,19 +27,17 @@ export const GET = withEmailAccount(async (request) => { labelId, }); - const tokens = await getTokens({ emailAccountId }); - if (!tokens) return NextResponse.json({ error: "Account not found" }); - - const gmail = getGmailClient(tokens); - const token = await getGmailAccessToken(tokens); + const { gmail, accessToken } = await getGmailAndAccessTokenForEmail({ + emailAccountId, + }); - if (!token.token) return NextResponse.json({ error: "Account not found" }); + if (!accessToken) return NextResponse.json({ error: "Account not found" }); const threads = await getThreads({ query, emailAccountId, gmail, - accessToken: token.token, + accessToken, }); return NextResponse.json(threads); }); diff --git a/apps/web/app/api/google/watch/all/route.ts b/apps/web/app/api/google/watch/all/route.ts index f5a0a829a..73d64c509 100644 --- a/apps/web/app/api/google/watch/all/route.ts +++ b/apps/web/app/api/google/watch/all/route.ts @@ -24,7 +24,6 @@ async function watchAllEmails() { }, select: { email: true, - aiApiKey: true, watchEmailsExpirationDate: true, account: { select: { @@ -36,6 +35,7 @@ async function watchAllEmails() { }, user: { select: { + aiApiKey: true, premium: { select: { aiAutomationAccess: true, @@ -60,11 +60,11 @@ async function watchAllEmails() { const userHasAiAccess = hasAiAccess( emailAccount.user.premium?.aiAutomationAccess, - emailAccount.aiApiKey, + emailAccount.user.aiApiKey, ); const userHasColdEmailAccess = hasColdEmailAccess( emailAccount.user.premium?.coldEmailBlockerAccess, - emailAccount.aiApiKey, + emailAccount.user.aiApiKey, ); if (!userHasAiAccess && !userHasColdEmailAccess) { @@ -113,9 +113,6 @@ async function watchAllEmails() { emailAccount.account.providerAccountId, ); - // couldn't refresh the token - if (!gmail) continue; - await watchEmails({ email: emailAccount.email, gmail }); } catch (error) { logger.error("Error for user", { userId: emailAccount.email, error }); diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index 5e338d065..ed1086016 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -135,12 +135,6 @@ export async function processHistoryForUser( emailAccount.account?.providerAccountId, ); - // couldn't refresh the token - if (!gmail) { - logger.error("Failed to refresh token", { email }); - return NextResponse.json({ ok: true }); - } - const startHistoryId = options?.startHistoryId || Math.max( @@ -212,9 +206,8 @@ export async function processHistoryForUser( } : error, }); + // returning 200 here, as otherwise PubSub will call the webhook over and over return NextResponse.json({ error: true }); - // be careful about calling an error here with the wrong settings, as otherwise PubSub will call the webhook over and over - // return NextResponse.error(); } } diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts index c703d443b..6241bda0a 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts @@ -75,7 +75,6 @@ async function handleBatchInternal(request: Request) { }, account.providerAccountId, ); - if (!gmail) return { error: "No Gmail client" }; const sendersWithEmails: Map = new Map(); @@ -104,7 +103,7 @@ async function handleBatchInternal(request: Request) { sender: result.sender, categories, categoryName: result.category ?? UNKNOWN_CATEGORY, - userEmail: emailAccount.email, + emailAccountId, }); } diff --git a/apps/web/app/api/user/stats/tinybird/load/route.ts b/apps/web/app/api/user/stats/tinybird/load/route.ts index 0a57de486..0f4726da6 100644 --- a/apps/web/app/api/user/stats/tinybird/load/route.ts +++ b/apps/web/app/api/user/stats/tinybird/load/route.ts @@ -2,9 +2,7 @@ import { NextResponse } from "next/server"; import { withEmailAccount } from "@/utils/middleware"; import { loadEmails } from "@/app/api/user/stats/tinybird/load/load-emails"; import { loadTinybirdEmailsBody } from "@/app/api/user/stats/tinybird/load/validation"; -import { getTokens } from "@/utils/account"; -import { getGmailClient } from "@/utils/gmail/client"; -import { getGmailAccessToken } from "@/utils/gmail/client"; +import { getGmailAndAccessTokenForEmail } from "@/utils/account"; export const maxDuration = 90; @@ -16,12 +14,10 @@ export const POST = withEmailAccount(async (request) => { const json = await request.json(); const body = loadTinybirdEmailsBody.parse(json); - const tokens = await getTokens({ emailAccountId }); + const { gmail, accessToken } = await getGmailAndAccessTokenForEmail({ + emailAccountId, + }); - const gmail = getGmailClient(tokens); - const token = await getGmailAccessToken(tokens); - - const accessToken = token.token; if (!accessToken) return NextResponse.json({ error: "Missing access token" }); const result = await loadEmails( diff --git a/apps/web/utils/account.ts b/apps/web/utils/account.ts index 7a5d88f62..3fc93cb49 100644 --- a/apps/web/utils/account.ts +++ b/apps/web/utils/account.ts @@ -1,19 +1,23 @@ -import { getGmailClient } from "@/utils/gmail/client"; +import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; import prisma from "@/utils/prisma"; +// export async function getTokens({ export async function getTokens({ emailAccountId, }: { emailAccountId: string }) { const emailAccount = await prisma.emailAccount.findUnique({ where: { id: emailAccountId }, select: { - account: { select: { access_token: true, refresh_token: true } }, + account: { + select: { access_token: true, refresh_token: true, expires_at: true }, + }, }, }); return { - accessToken: emailAccount?.account.access_token ?? undefined, - refreshToken: emailAccount?.account.refresh_token ?? undefined, + accessToken: emailAccount?.account.access_token, + refreshToken: emailAccount?.account.refresh_token, + expiryDate: emailAccount?.account.expires_at, }; } @@ -25,6 +29,14 @@ export async function getGmailClientForEmail({ return gmail; } +export async function getGmailAndAccessTokenForEmail({ + emailAccountId, +}: { emailAccountId: string }) { + const tokens = await getTokens({ emailAccountId }); + const gmailAndAccessToken = await getGmailAccessToken(tokens); + return { ...gmailAndAccessToken, tokens }; +} + export async function getGmailClientForEmailId({ emailAccountId, }: { diff --git a/apps/web/utils/actions/permissions.ts b/apps/web/utils/actions/permissions.ts index 9a90b53fa..3d1f04eb3 100644 --- a/apps/web/utils/actions/permissions.ts +++ b/apps/web/utils/actions/permissions.ts @@ -1,11 +1,10 @@ "use server"; import { z } from "zod"; -import { getGmailAccessToken } from "@/utils/gmail/client"; import { handleGmailPermissionsCheck } from "@/utils/gmail/permissions"; import { createScopedLogger } from "@/utils/logger"; import { actionClient, adminActionClient } from "@/utils/actions/safe-action"; -import { getTokens } from "@/utils/account"; +import { getGmailAndAccessTokenForEmail } from "@/utils/account"; import prisma from "@/utils/prisma"; const logger = createScopedLogger("actions/permissions"); @@ -14,20 +13,21 @@ export const checkPermissionsAction = actionClient .metadata({ name: "checkPermissions" }) .action(async ({ ctx: { emailAccountId } }) => { try { - const tokens = await getTokens({ emailAccountId }); - const accessToken = await getGmailAccessToken(tokens); + const { accessToken, tokens } = await getGmailAndAccessTokenForEmail({ + emailAccountId, + }); - if (!accessToken.token) return { error: "No access token" }; + if (!accessToken) return { error: "No access token" }; const { hasAllPermissions, error } = await handleGmailPermissionsCheck({ - accessToken: accessToken.token, + accessToken, emailAccountId, }); if (error) return { error }; if (!hasAllPermissions) return { hasAllPermissions: false }; - if (!tokens?.refreshToken) + if (!tokens.refreshToken) return { hasRefreshToken: false, hasAllPermissions }; return { hasRefreshToken: true, hasAllPermissions }; @@ -49,21 +49,18 @@ export const adminCheckPermissionsAction = adminActionClient where: { email }, select: { id: true, - account: { select: { access_token: true, refresh_token: true } }, }, }); if (!emailAccount) return { error: "Email account not found" }; const emailAccountId = emailAccount.id; - if (!emailAccount.account?.access_token) - return { error: "No access token" }; - const token = await getGmailAccessToken({ - accessToken: emailAccount.account.access_token ?? undefined, - refreshToken: emailAccount.account.refresh_token ?? undefined, + + const { accessToken } = await getGmailAndAccessTokenForEmail({ + emailAccountId, }); - if (!token.token) return { error: "No Gmail access token" }; + if (!accessToken) return { error: "No Gmail access token" }; const { hasAllPermissions, error } = await handleGmailPermissionsCheck({ - accessToken: token.token, + accessToken, emailAccountId, }); if (error) return { error }; diff --git a/apps/web/utils/api-auth.test.ts b/apps/web/utils/api-auth.test.ts index fee87743d..816275d90 100644 --- a/apps/web/utils/api-auth.test.ts +++ b/apps/web/utils/api-auth.test.ts @@ -210,8 +210,8 @@ describe("api-auth", () => { } as MockApiKeyResult); // Mock getGmailClientWithRefresh to return null (refresh failed) - vi.mocked(gmailClient.getGmailClientWithRefresh).mockResolvedValue( - undefined, + vi.mocked(gmailClient.getGmailClientWithRefresh).mockRejectedValue( + new Error("Error refreshing Gmail access token"), ); await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( diff --git a/apps/web/utils/api-auth.ts b/apps/web/utils/api-auth.ts index 35c32aad9..507a1fbbe 100644 --- a/apps/web/utils/api-auth.ts +++ b/apps/web/utils/api-auth.ts @@ -67,6 +67,7 @@ export async function getUserFromApiKey(secretKey: string) { export async function validateApiKeyAndGetGmailClient(request: NextRequest) { const { user } = await validateApiKey(request); + // TODO: support API For multiple accounts const account = user.accounts[0]; if (!account) throw new SafeError("Missing account", 401); @@ -83,8 +84,6 @@ export async function validateApiKeyAndGetGmailClient(request: NextRequest) { account.providerAccountId, ); - if (!gmail) throw new SafeError("Error refreshing Gmail access token", 401); - return { gmail, accessToken: account.access_token, diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 7b4cee147..c619e5074 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -92,7 +92,7 @@ export const getAuthOptions: (options?: { // On future log ins, we retrieve the `refresh_token` from the database if (account.refresh_token) { logger.info("Saving refresh token", { email: token.email }); - await saveRefreshToken( + await saveTokens( { access_token: account.access_token, refresh_token: account.refresh_token, @@ -290,7 +290,7 @@ const refreshAccessToken = async (token: JWT): Promise => { : "undefined", }); - await saveRefreshToken( + await saveTokens( { ...tokens, expires_at }, { providerAccountId: account.providerAccountId, @@ -326,7 +326,7 @@ function calculateExpiresAt(expiresIn?: number) { return Math.floor(Date.now() / 1000 + (expiresIn - 10)); // give 10 second buffer } -export async function saveRefreshToken( +export async function saveTokens( tokens: { access_token?: string; refresh_token?: string; @@ -335,13 +335,12 @@ export async function saveRefreshToken( account: Pick, ) { const refreshToken = tokens.refresh_token ?? account.refresh_token; + const providerAccountId = account.providerAccountId; if (!refreshToken) { - logger.error("Attempted to save null refresh token", { - providerAccountId: account.providerAccountId, - }); + logger.error("Attempted to save null refresh token", { providerAccountId }); captureException("Cannot save null refresh token", { - extra: { providerAccountId: account.providerAccountId }, + extra: { providerAccountId }, }); return; } @@ -355,7 +354,7 @@ export async function saveRefreshToken( where: { provider_providerAccountId: { provider: "google", - providerAccountId: account.providerAccountId, + providerAccountId, }, }, }); diff --git a/apps/web/utils/gmail/client.ts b/apps/web/utils/gmail/client.ts index 0727b8fcc..d87a943e6 100644 --- a/apps/web/utils/gmail/client.ts +++ b/apps/web/utils/gmail/client.ts @@ -1,10 +1,15 @@ import { auth, gmail, type gmail_v1 } from "@googleapis/gmail"; import { people } from "@googleapis/people"; -import { saveRefreshToken } from "@/utils/auth"; +import { saveTokens } from "@/utils/auth"; import { env } from "@/env"; import { createScopedLogger } from "@/utils/logger"; import { SCOPES } from "@/utils/gmail/scopes"; +// TODO: this file needs some clean up +// We're returning different clients and can run into situations with expired access tokens +// Ideally we refresh access token when needed and store the new access token in the db +// Also we shouldn't be instantiating multiple clients as they can hold different tokens, where we refresh the access token for one client but not for the other + const logger = createScopedLogger("gmail/client"); type AuthOptions = { @@ -28,21 +33,42 @@ const getAuth = ({ accessToken, refreshToken, expiryDate }: AuthOptions) => { return googleAuth; }; -export const getGmailClient = (options: AuthOptions) => { - const auth = getAuth(options); +// doesn't handle refreshing the access token +export const getGmailClient = ({ accessToken, refreshToken }: AuthOptions) => { + // not passing in expiryDate, so it won't refresh the access token + const auth = getAuth({ accessToken, refreshToken }); return gmail({ version: "v1", auth }); }; -export const getContactsClient = (session: AuthOptions) => { - const auth = getAuth(session); +// doesn't handle refreshing the access token +export const getContactsClient = ({ + accessToken, + refreshToken, +}: AuthOptions) => { + const auth = getAuth({ accessToken, refreshToken }); const contacts = people({ version: "v1", auth }); return contacts; }; +// handles refreshing the access token if expired at is passed in, but doesn't save the new access token to the db export const getGmailAccessToken = async (options: AuthOptions) => { const auth = getAuth(options); - return auth.getAccessToken(); + const token = await auth.getAccessToken(); + const g = gmail({ version: "v1", auth }); + + // TODO: save the new access token to the db + // if (token.token && token.token !== options.accessToken) { + // await saveRefreshToken( + // { + // access_token: token.token, + // expires_at: token.res?.data.expires_at, + // }, + // { refresh_token: options.refreshToken }, + // ); + // } + + return { gmail: g, accessToken: token.token }; }; export const getAccessTokenFromClient = (client: gmail_v1.Gmail): string => { @@ -52,14 +78,16 @@ export const getAccessTokenFromClient = (client: gmail_v1.Gmail): string => { return accessToken; }; +// we should potentially use this everywhere instead of getGmailClient as this handles refreshing the access token and saving it to the db export const getGmailClientWithRefresh = async ( options: AuthOptions & { refreshToken: string; expiryDate?: number | null }, providerAccountId: string, -): Promise => { +): Promise => { const auth = getAuth(options); const g = gmail({ version: "v1", auth }); - if (options.expiryDate && options.expiryDate > Date.now()) return g; + const { expiryDate } = options; + if (expiryDate && expiryDate > Date.now()) return g; // may throw `invalid_grant` error try { @@ -67,7 +95,7 @@ export const getGmailClientWithRefresh = async ( const newAccessToken = tokens.credentials.access_token; if (newAccessToken !== options.accessToken) { - await saveRefreshToken( + await saveTokens( { access_token: newAccessToken ?? undefined, expires_at: tokens.credentials.expiry_date @@ -83,11 +111,14 @@ export const getGmailClientWithRefresh = async ( return g; } catch (error) { - if (error instanceof Error && error.message.includes("invalid_grant")) { + if (isInvalidGrantError(error)) { logger.warn("Error refreshing Gmail access token", { error }); - return undefined; } throw error; } }; + +const isInvalidGrantError = (error: unknown) => { + return error instanceof Error && error.message.includes("invalid_grant"); +}; From 941a6953f6e9dd08f92e193af1b04dcf3952d5bf Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:47:19 +0300 Subject: [PATCH 076/176] clean up --- apps/web/app/api/google/contacts/route.ts | 19 +++++++++--- apps/web/utils/account.ts | 37 +++++++++++------------ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/web/app/api/google/contacts/route.ts b/apps/web/app/api/google/contacts/route.ts index c044eddec..804ac6696 100644 --- a/apps/web/app/api/google/contacts/route.ts +++ b/apps/web/app/api/google/contacts/route.ts @@ -5,7 +5,7 @@ import { withEmailAccount } from "@/utils/middleware"; import { getContactsClient } from "@/utils/gmail/client"; import { searchContacts } from "@/utils/gmail/contact"; import { env } from "@/env"; -import { getTokens } from "@/utils/account"; +import prisma from "@/utils/prisma"; const contactsQuery = z.object({ query: z.string() }); export type ContactsQuery = z.infer; @@ -20,10 +20,21 @@ export const GET = withEmailAccount(async (request) => { if (!env.NEXT_PUBLIC_CONTACTS_ENABLED) return NextResponse.json({ error: "Contacts API not enabled" }); - const tokens = await getTokens({ - emailAccountId: request.auth.emailAccountId, + const emailAccountId = request.auth.emailAccountId; + + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + account: { + select: { access_token: true, refresh_token: true, expires_at: true }, + }, + }, + }); + const client = getContactsClient({ + accessToken: emailAccount?.account.access_token, + refreshToken: emailAccount?.account.refresh_token, + expiryDate: emailAccount?.account.expires_at, }); - const client = getContactsClient(tokens); const { searchParams } = new URL(request.url); const query = searchParams.get("query"); diff --git a/apps/web/utils/account.ts b/apps/web/utils/account.ts index 3fc93cb49..b9764db30 100644 --- a/apps/web/utils/account.ts +++ b/apps/web/utils/account.ts @@ -1,26 +1,6 @@ import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; import prisma from "@/utils/prisma"; -// export async function getTokens({ -export async function getTokens({ - emailAccountId, -}: { emailAccountId: string }) { - const emailAccount = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { access_token: true, refresh_token: true, expires_at: true }, - }, - }, - }); - - return { - accessToken: emailAccount?.account.access_token, - refreshToken: emailAccount?.account.refresh_token, - expiryDate: emailAccount?.account.expires_at, - }; -} - export async function getGmailClientForEmail({ emailAccountId, }: { emailAccountId: string }) { @@ -54,3 +34,20 @@ export async function getGmailClientForEmailId({ }); return gmail; } + +async function getTokens({ emailAccountId }: { emailAccountId: string }) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + account: { + select: { access_token: true, refresh_token: true, expires_at: true }, + }, + }, + }); + + return { + accessToken: emailAccount?.account.access_token, + refreshToken: emailAccount?.account.refresh_token, + expiryDate: emailAccount?.account.expires_at, + }; +} From f0d933887b97df34d301b7a0eeae0459ec7e8059 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:48:45 +0300 Subject: [PATCH 077/176] more fixes --- apps/web/utils/actions/reply-tracking.ts | 51 +++++++++++-------- apps/web/utils/llms/types.ts | 2 +- .../reply-tracker/check-previous-emails.ts | 12 +++-- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/apps/web/utils/actions/reply-tracking.ts b/apps/web/utils/actions/reply-tracking.ts index 847970f94..a75527afa 100644 --- a/apps/web/utils/actions/reply-tracking.ts +++ b/apps/web/utils/actions/reply-tracking.ts @@ -13,13 +13,14 @@ import { enableReplyTracker } from "@/utils/reply-tracker/enable"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; import { SafeError } from "@/utils/error"; +import { getEmailAccountWithAi } from "@/utils/user/get"; const logger = createScopedLogger("enableReplyTracker"); export const enableReplyTrackerAction = actionClient .metadata({ name: "enableReplyTracker" }) - .action(async ({ ctx: { email } }) => { - await enableReplyTracker({ email }); + .action(async ({ ctx: { emailAccountId } }) => { + await enableReplyTracker({ emailAccountId }); revalidatePath("/reply-zero"); @@ -28,11 +29,12 @@ export const enableReplyTrackerAction = actionClient export const processPreviousSentEmailsAction = actionClient .metadata({ name: "processPreviousSentEmails" }) - .action(async ({ ctx: { email, emailAccount } }) => { + .action(async ({ ctx: { emailAccountId } }) => { + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); if (!emailAccount) throw new SafeError("Email account not found"); - const gmail = await getGmailClientForEmail({ email }); - await processPreviousSentEmails(gmail, emailAccount); + const gmail = await getGmailClientForEmail({ emailAccountId }); + await processPreviousSentEmails({ gmail, emailAccount }); return { success: true }; }); @@ -45,24 +47,29 @@ const resolveThreadTrackerSchema = z.object({ export const resolveThreadTrackerAction = actionClient .metadata({ name: "resolveThreadTracker" }) .schema(resolveThreadTrackerSchema) - .action(async ({ ctx: { email }, parsedInput: { threadId, resolved } }) => { - await startAnalyzingReplyTracker({ email }).catch((error) => { - logger.error("Error starting Reply Zero analysis", { error }); - }); + .action( + async ({ + ctx: { emailAccountId }, + parsedInput: { threadId, resolved }, + }) => { + await startAnalyzingReplyTracker({ emailAccountId }).catch((error) => { + logger.error("Error starting Reply Zero analysis", { error }); + }); - await prisma.threadTracker.updateMany({ - where: { - threadId, - emailAccountId: email, - }, - data: { resolved }, - }); + await prisma.threadTracker.updateMany({ + where: { + threadId, + emailAccountId, + }, + data: { resolved }, + }); - await stopAnalyzingReplyTracker({ email }).catch((error) => { - logger.error("Error stopping Reply Zero analysis", { error }); - }); + await stopAnalyzingReplyTracker({ emailAccountId }).catch((error) => { + logger.error("Error stopping Reply Zero analysis", { error }); + }); - revalidatePath("/reply-zero"); + revalidatePath("/reply-zero"); - return { success: true }; - }); + return { success: true }; + }, + ); diff --git a/apps/web/utils/llms/types.ts b/apps/web/utils/llms/types.ts index 114dc8265..e7a7218da 100644 --- a/apps/web/utils/llms/types.ts +++ b/apps/web/utils/llms/types.ts @@ -1,4 +1,4 @@ -import type { User, EmailAccount, Prisma } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; export type UserAIFields = Prisma.UserGetPayload<{ select: { diff --git a/apps/web/utils/reply-tracker/check-previous-emails.ts b/apps/web/utils/reply-tracker/check-previous-emails.ts index 99f536623..a35ce7c0a 100644 --- a/apps/web/utils/reply-tracker/check-previous-emails.ts +++ b/apps/web/utils/reply-tracker/check-previous-emails.ts @@ -11,11 +11,15 @@ import prisma from "@/utils/prisma"; const logger = createScopedLogger("reply-tracker/check-previous-emails"); -export async function processPreviousSentEmails( - gmail: gmail_v1.Gmail, - emailAccount: EmailAccountWithAI, +export async function processPreviousSentEmails({ + gmail, + emailAccount, maxResults = 100, -) { +}: { + gmail: gmail_v1.Gmail; + emailAccount: EmailAccountWithAI; + maxResults?: number; +}) { const assistantEmail = getAssistantEmail({ userEmail: emailAccount.email }); // Get last sent messages From d5bb16689c7deeb1d6f7ee10539e61860654be9b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:52:27 +0300 Subject: [PATCH 078/176] more fixes --- .../reply-tracker/process-previous/route.ts | 31 +--- apps/web/utils/actions/rule.ts | 170 ++++++++++-------- 2 files changed, 99 insertions(+), 102 deletions(-) diff --git a/apps/web/app/api/reply-tracker/process-previous/route.ts b/apps/web/app/api/reply-tracker/process-previous/route.ts index e0977b516..b5c3ae659 100644 --- a/apps/web/app/api/reply-tracker/process-previous/route.ts +++ b/apps/web/app/api/reply-tracker/process-previous/route.ts @@ -3,16 +3,16 @@ import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { withError } from "@/utils/middleware"; import { processPreviousSentEmails } from "@/utils/reply-tracker/check-previous-emails"; -import { getGmailClient } from "@/utils/gmail/client"; -import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; import { isValidInternalApiKey } from "@/utils/internal-api"; +import { getEmailAccountWithAi } from "@/utils/user/get"; +import { getGmailClientForEmail } from "@/utils/account"; const logger = createScopedLogger("api/reply-tracker/process-previous"); export const maxDuration = 300; -const processPreviousSchema = z.object({ email: z.string() }); +const processPreviousSchema = z.object({ emailAccountId: z.string() }); export type ProcessPreviousBody = z.infer; export const POST = withError(async (request: Request) => { @@ -23,29 +23,14 @@ export const POST = withError(async (request: Request) => { const json = await request.json(); const body = processPreviousSchema.parse(json); - const email = body.email; - - const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, - include: { - account: { - select: { access_token: true, refresh_token: true }, - }, - }, - }); - if (!emailAccount) return NextResponse.json({ error: "User not found" }); - - logger.info("Processing previous emails for user", { email }); + const emailAccountId = body.emailAccountId; - if (!emailAccount.account?.access_token) - return NextResponse.json({ error: "No access token or refresh token" }); + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); + if (!emailAccount) return NextResponse.json({ error: "User not found" }); - const gmail = getGmailClient({ - accessToken: emailAccount.account.access_token, - refreshToken: emailAccount.account.refresh_token ?? undefined, - }); + const gmail = await getGmailClientForEmail({ emailAccountId }); - await processPreviousSentEmails(gmail, emailAccount); + await processPreviousSentEmails({ gmail, emailAccount }); return NextResponse.json({ success: true }); }); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 061406a0f..c64fc2d87 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -40,6 +40,7 @@ import type { ProcessPreviousBody } from "@/app/api/reply-tracker/process-previo import { RuleName } from "@/utils/rule/consts"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; +import { getEmailAccountWithAi } from "@/utils/user/get"; const logger = createScopedLogger("actions/rule"); @@ -48,7 +49,7 @@ export const createRuleAction = actionClient .schema(createRuleBody) .action( async ({ - ctx: { email }, + ctx: { emailAccountId }, parsedInput: { name, automate, @@ -87,7 +88,7 @@ export const createRuleAction = actionClient }, } : undefined, - emailAccount: { connect: { email } }, + emailAccountId, conditionalOperator: conditionalOperator || LogicalOperator.AND, systemType: systemType || undefined, // conditions @@ -107,7 +108,7 @@ export const createRuleAction = actionClient include: { actions: true, categoryFilters: true, group: true }, }); - await updatePromptFileOnRuleCreated({ email, rule }); + await updatePromptFileOnRuleCreated({ emailAccountId, rule }); revalidatePath("/automation"); @@ -133,7 +134,7 @@ export const updateRuleAction = actionClient .schema(updateRuleBody) .action( async ({ - ctx: { email }, + ctx: { emailAccountId }, parsedInput: { id, name, @@ -149,7 +150,7 @@ export const updateRuleAction = actionClient try { const currentRule = await prisma.rule.findUnique({ - where: { id, emailAccountId: email }, + where: { id, emailAccountId }, include: { actions: true, categoryFilters: true, group: true }, }); if (!currentRule) return { error: "Rule not found" }; @@ -165,7 +166,7 @@ export const updateRuleAction = actionClient const [updatedRule] = await prisma.$transaction([ // update rule prisma.rule.update({ - where: { id, emailAccountId: email }, + where: { id, emailAccountId }, data: { automate: automate ?? undefined, runOnThreads: runOnThreads ?? undefined, @@ -238,7 +239,7 @@ export const updateRuleAction = actionClient // update prompt file await updatePromptFileOnRuleUpdated({ - email, + emailAccountId, currentRule, updatedRule, }); @@ -266,51 +267,55 @@ export const updateRuleAction = actionClient export const updateRuleInstructionsAction = actionClient .metadata({ name: "updateRuleInstructions" }) .schema(updateRuleInstructionsBody) - .action(async ({ ctx: { email }, parsedInput: { id, instructions } }) => { - const currentRule = await prisma.rule.findUnique({ - where: { id, emailAccountId: email }, - include: { actions: true, categoryFilters: true, group: true }, - }); - if (!currentRule) return { error: "Rule not found" }; + .action( + async ({ ctx: { emailAccountId }, parsedInput: { id, instructions } }) => { + const currentRule = await prisma.rule.findUnique({ + where: { id, emailAccountId }, + include: { actions: true, categoryFilters: true, group: true }, + }); + if (!currentRule) return { error: "Rule not found" }; - await updateRuleInstructionsAndPromptFile({ - email, - ruleId: id, - instructions, - currentRule, - }); + await updateRuleInstructionsAndPromptFile({ + emailAccountId, + ruleId: id, + instructions, + currentRule, + }); - revalidatePath(`/automation/rule/${id}`); - revalidatePath("/automation"); - }); + revalidatePath(`/automation/rule/${id}`); + revalidatePath("/automation"); + }, + ); export const updateRuleSettingsAction = actionClient .metadata({ name: "updateRuleSettings" }) .schema(updateRuleSettingsBody) - .action(async ({ ctx: { email }, parsedInput: { id, instructions } }) => { - const currentRule = await prisma.rule.findUnique({ - where: { id, emailAccountId: email }, - }); - if (!currentRule) return { error: "Rule not found" }; + .action( + async ({ ctx: { emailAccountId }, parsedInput: { id, instructions } }) => { + const currentRule = await prisma.rule.findUnique({ + where: { id, emailAccountId }, + }); + if (!currentRule) return { error: "Rule not found" }; - await prisma.rule.update({ - where: { id, emailAccountId: email }, - data: { instructions }, - }); + await prisma.rule.update({ + where: { id, emailAccountId }, + data: { instructions }, + }); - revalidatePath(`/automation/rule/${id}`); - revalidatePath("/automation"); - revalidatePath("/reply-zero"); - }); + revalidatePath(`/automation/rule/${id}`); + revalidatePath("/automation"); + revalidatePath("/reply-zero"); + }, + ); export const enableDraftRepliesAction = actionClient .metadata({ name: "enableDraftReplies" }) .schema(enableDraftRepliesBody) - .action(async ({ ctx: { email }, parsedInput: { enable } }) => { + .action(async ({ ctx: { emailAccountId }, parsedInput: { enable } }) => { const rule = await prisma.rule.findUnique({ where: { emailAccountId_systemType: { - emailAccountId: email, + emailAccountId, systemType: SystemType.TO_REPLY, }, }, @@ -337,32 +342,37 @@ export const enableDraftRepliesAction = actionClient export const deleteRuleAction = actionClient .metadata({ name: "deleteRule" }) .schema(deleteRuleBody) - .action(async ({ ctx: { email }, parsedInput: { id } }) => { + .action(async ({ ctx: { emailAccountId }, parsedInput: { id } }) => { const rule = await prisma.rule.findUnique({ - where: { id, emailAccountId: email }, + where: { id, emailAccountId }, include: { actions: true, categoryFilters: true, group: true }, }); if (!rule) return; // already deleted - if (rule.emailAccountId !== email) + if (rule.emailAccountId !== emailAccountId) return { error: "You don't have permission to delete this rule" }; try { await deleteRule({ ruleId: id, - email, + emailAccountId, groupId: rule.groupId, }); const emailAccount = await prisma.emailAccount.findUnique({ - where: { email }, + where: { id: emailAccountId }, select: { + id: true, userId: true, email: true, about: true, - aiModel: true, - aiProvider: true, - aiApiKey: true, rulesPrompt: true, + user: { + select: { + aiModel: true, + aiProvider: true, + aiApiKey: true, + }, + }, }, }); if (!emailAccount) return { error: "User not found" }; @@ -370,13 +380,13 @@ export const deleteRuleAction = actionClient if (!emailAccount.rulesPrompt) return; const updatedPrompt = await generatePromptOnDeleteRule({ - user: emailAccount, + emailAccount, existingPrompt: emailAccount.rulesPrompt, deletedRule: rule, }); await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { rulesPrompt: updatedPrompt }, }); @@ -391,28 +401,27 @@ export const deleteRuleAction = actionClient export const getRuleExamplesAction = actionClient .metadata({ name: "getRuleExamples" }) .schema(rulesExamplesBody) - .action( - async ({ ctx: { email, emailAccount }, parsedInput: { rulesPrompt } }) => { - if (!emailAccount) throw new SafeError("Email account not found"); + .action(async ({ ctx: { emailAccountId }, parsedInput: { rulesPrompt } }) => { + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); + if (!emailAccount) throw new SafeError("Email account not found"); - const gmail = await getGmailClientForEmail({ email }); + const gmail = await getGmailClientForEmail({ emailAccountId }); - const { matches } = await aiFindExampleMatches( - emailAccount, - gmail, - rulesPrompt, - ); + const { matches } = await aiFindExampleMatches( + emailAccount, + gmail, + rulesPrompt, + ); - return { matches }; - }, - ); + return { matches }; + }); export const createRulesOnboardingAction = actionClient .metadata({ name: "createRulesOnboarding" }) .schema(createRulesOnboardingBody) .action( async ({ - ctx: { email }, + ctx: { emailAccountId }, parsedInput: { newsletter, coldEmail, @@ -423,11 +432,11 @@ export const createRulesOnboardingAction = actionClient notification, }, }) => { - const user = await prisma.emailAccount.findUnique({ - where: { email }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, select: { rulesPrompt: true }, }); - if (!user) return { error: "User not found" }; + if (!emailAccount) return { error: "User not found" }; const promises: Promise[] = []; @@ -437,7 +446,7 @@ export const createRulesOnboardingAction = actionClient // cold email blocker if (isSet(coldEmail)) { const promise = prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { coldEmailBlocker: coldEmail === "label" @@ -452,7 +461,7 @@ export const createRulesOnboardingAction = actionClient // reply tracker if (isSet(toReply)) { - const promise = enableReplyTracker({ email }).then((res) => { + const promise = enableReplyTracker({ emailAccountId }).then((res) => { if (res?.alreadyEnabled) return; // Load previous emails needing replies in background @@ -465,7 +474,9 @@ export const createRulesOnboardingAction = actionClient "Content-Type": "application/json", [INTERNAL_API_KEY_HEADER]: env.INTERNAL_API_KEY, }, - body: JSON.stringify({ email } satisfies ProcessPreviousBody), + body: JSON.stringify({ + emailAccountId, + } satisfies ProcessPreviousBody), }, ); }); @@ -579,10 +590,10 @@ export const createRulesOnboardingAction = actionClient newsletter, "Newsletter", SystemType.NEWSLETTER, - email, + emailAccountId, ); } else { - deleteRule(SystemType.NEWSLETTER, email); + deleteRule(SystemType.NEWSLETTER, emailAccountId); } // marketing @@ -595,10 +606,10 @@ export const createRulesOnboardingAction = actionClient marketing, "Marketing", SystemType.MARKETING, - email, + emailAccountId, ); } else { - deleteRule(SystemType.MARKETING, email); + deleteRule(SystemType.MARKETING, emailAccountId); } // calendar @@ -611,10 +622,10 @@ export const createRulesOnboardingAction = actionClient calendar, "Calendar", SystemType.CALENDAR, - email, + emailAccountId, ); } else { - deleteRule(SystemType.CALENDAR, email); + deleteRule(SystemType.CALENDAR, emailAccountId); } // receipt @@ -627,10 +638,10 @@ export const createRulesOnboardingAction = actionClient receipt, "Receipt", SystemType.RECEIPT, - email, + emailAccountId, ); } else { - deleteRule(SystemType.RECEIPT, email); + deleteRule(SystemType.RECEIPT, emailAccountId); } // notification @@ -643,19 +654,20 @@ export const createRulesOnboardingAction = actionClient notification, "Notification", SystemType.NOTIFICATION, - email, + emailAccountId, ); } else { - deleteRule(SystemType.NOTIFICATION, email); + deleteRule(SystemType.NOTIFICATION, emailAccountId); } await Promise.allSettled(promises); await prisma.emailAccount.update({ - where: { email }, + where: { id: emailAccountId }, data: { - rulesPrompt: - `${user.rulesPrompt || ""}\n${rules.map((r) => `* ${r}`).join("\n")}`.trim(), + rulesPrompt: `${emailAccount.rulesPrompt || ""}\n${rules + .map((r) => `* ${r}`) + .join("\n")}`.trim(), }, }); }, From 44aff847fc2d0ad0b6b5cda470802f59d92dcaa3 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 02:10:37 +0300 Subject: [PATCH 079/176] fixes --- .../call-analyze-pattern-api.ts | 4 +- apps/web/utils/actions/ai-rule.ts | 1 + apps/web/utils/actions/categorize.ts | 12 +- apps/web/utils/ai/actions.ts | 159 +++++++++--------- apps/web/utils/ai/choose-rule/execute.ts | 9 +- apps/web/utils/ai/choose-rule/run-rules.ts | 9 +- apps/web/utils/upstash/categorize-senders.ts | 29 ++-- 7 files changed, 113 insertions(+), 110 deletions(-) diff --git a/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts b/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts index eadcca0c6..54b6933b8 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts @@ -21,7 +21,7 @@ export async function analyzeSenderPattern(body: AnalyzeSenderPatternBody) { if (!response.ok) { logger.error("Sender pattern analysis API request failed", { - email: body.email, + emailAccountId: body.emailAccountId, from: body.from, status: response.status, statusText: response.statusText, @@ -29,7 +29,7 @@ export async function analyzeSenderPattern(body: AnalyzeSenderPatternBody) { } } catch (error) { logger.error("Error in sender pattern analysis", { - email: body.email, + emailAccountId: body.emailAccountId, from: body.from, error: error instanceof Error ? error.message : error, }); diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index fa35e22ac..9946a776c 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -220,6 +220,7 @@ export const approvePlanAction = actionClient message, executedRule, userEmail: emailAccount.email, + emailAccountId, }); }, ); diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index fe8986939..8cd08aedb 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -29,16 +29,16 @@ const logger = createScopedLogger("actions/categorize"); export const bulkCategorizeSendersAction = actionClient .metadata({ name: "bulkCategorizeSenders" }) - .action(async ({ ctx: { emailAccountId, userEmail } }) => { + .action(async ({ ctx: { emailAccountId } }) => { await validateUserAndAiAccess({ emailAccountId }); // Delete empty queues as Qstash has a limit on how many queues we can have // We could run this in a cron too but simplest to do here for now - deleteEmptyCategorizeSendersQueues({ skipEmail: userEmail }).catch( - (error) => { - logger.error("Error deleting empty queues", { error }); - }, - ); + deleteEmptyCategorizeSendersQueues({ + skipEmailAccountId: emailAccountId, + }).catch((error) => { + logger.error("Error deleting empty queues", { error }); + }); const LIMIT = 100; diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 57ad7b407..1f44ed879 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -22,21 +22,24 @@ import { internalDateToDate } from "@/utils/date"; const logger = createScopedLogger("ai-actions"); -type ActionFunction> = ( - gmail: gmail_v1.Gmail, - email: EmailForAction, - args: T, - userEmail: string, - executedRule: ExecutedRule, -) => Promise; - -export const runActionFunction = async ( - gmail: gmail_v1.Gmail, - email: EmailForAction, - action: ActionItem, - userEmail: string, - executedRule: ExecutedRule, -) => { +type ActionFunction> = (options: { + gmail: gmail_v1.Gmail; + email: EmailForAction; + args: T; + userEmail: string; + emailAccountId: string; + executedRule: ExecutedRule; +}) => Promise; + +export const runActionFunction = async (options: { + gmail: gmail_v1.Gmail; + email: EmailForAction; + action: ActionItem; + userEmail: string; + emailAccountId: string; + executedRule: ExecutedRule; +}) => { + const { action, userEmail } = options; logger.info("Running action", { actionType: action.type, userEmail, @@ -45,38 +48,41 @@ export const runActionFunction = async ( logger.trace("Running action:", action); const { type, ...args } = action; + const opts = { + ...options, + args, + }; switch (type) { case ActionType.ARCHIVE: - return archive(gmail, email, args, userEmail, executedRule); + return archive(opts); case ActionType.LABEL: - return label(gmail, email, args, userEmail, executedRule); + return label(opts); case ActionType.DRAFT_EMAIL: - return draft(gmail, email, args, userEmail, executedRule); + return draft(opts); case ActionType.REPLY: - return reply(gmail, email, args, userEmail, executedRule); + return reply(opts); case ActionType.SEND_EMAIL: - return send_email(gmail, email, args, userEmail, executedRule); + return send_email(opts); case ActionType.FORWARD: - return forward(gmail, email, args, userEmail, executedRule); + return forward(opts); case ActionType.MARK_SPAM: - return mark_spam(gmail, email, args, userEmail, executedRule); + return mark_spam(opts); case ActionType.CALL_WEBHOOK: - return call_webhook(gmail, email, args, userEmail, executedRule); + return call_webhook(opts); case ActionType.MARK_READ: - return mark_read(gmail, email, args, userEmail, executedRule); + return mark_read(opts); case ActionType.TRACK_THREAD: - return track_thread(gmail, email, args, userEmail, executedRule); + return track_thread(opts); default: throw new Error(`Unknown action: ${action}`); } }; -const archive: ActionFunction> = async ( +const archive: ActionFunction> = async ({ gmail, email, - _args, userEmail, -) => { +}) => { await archiveThread({ gmail, threadId: email.threadId, @@ -85,11 +91,11 @@ const archive: ActionFunction> = async ( }); }; -const label: ActionFunction<{ label: string } | any> = async ( +const label: ActionFunction<{ label: string } | any> = async ({ gmail, email, args, -) => { +}) => { if (!args.label) return; const label = await getOrCreateLabel({ @@ -106,32 +112,26 @@ const label: ActionFunction<{ label: string } | any> = async ( }); }; -const draft: ActionFunction = async ( - gmail, - email, - args: { - to: string; - subject: string; - content: string; - attachments?: Attachment[]; - }, -) => { +// args: { +// to: string; +// subject: string; +// content: string; +// attachments?: Attachment[]; +// }, +const draft: ActionFunction = async ({ gmail, email, args }) => { const result = await draftEmail(gmail, email, args); return { draftId: result.data.message?.id }; }; -const send_email: ActionFunction = async ( - gmail, - _email, - args: { - to: string; - subject: string; - content: string; - cc: string; - bcc: string; - attachments?: Attachment[]; - }, -) => { +// args: { +// to: string; +// subject: string; +// content: string; +// cc: string; +// bcc: string; +// attachments?: Attachment[]; +// }, +const send_email: ActionFunction = async ({ gmail, args }) => { await sendEmailWithPlainText(gmail, { to: args.to, cc: args.cc, @@ -142,29 +142,23 @@ const send_email: ActionFunction = async ( }); }; -const reply: ActionFunction = async ( - gmail, - email, - args: { - content: string; - cc?: string; - bcc?: string; - attachments?: Attachment[]; - }, -) => { +// args: { +// content: string; +// cc?: string; +// bcc?: string; +// attachments?: Attachment[]; +// }, +const reply: ActionFunction = async ({ gmail, email, args }) => { await replyToEmail(gmail, email, args.content, email.headers.from); }; -const forward: ActionFunction = async ( - gmail, - email, - args: { - to: string; - content: string; - cc: string; - bcc: string; - }, -) => { +// args: { +// to: string; +// content: string; +// cc: string; +// bcc: string; +// }, +const forward: ActionFunction = async ({ gmail, email, args }) => { // We may need to make sure the AI isn't adding the extra forward content on its own await forwardEmail(gmail, { messageId: email.id, @@ -175,17 +169,17 @@ const forward: ActionFunction = async ( }); }; -const mark_spam: ActionFunction = async (gmail, email) => { +const mark_spam: ActionFunction = async ({ gmail, email }) => { return await markSpam({ gmail, threadId: email.threadId }); }; -const call_webhook: ActionFunction = async ( - _gmail, +// args: { url: string }, +const call_webhook: ActionFunction = async ({ email, - args: { url: string }, + args, userEmail, executedRule, -) => { +}) => { await callWebhook(userEmail, args.url, { email: { threadId: email.threadId, @@ -206,18 +200,17 @@ const call_webhook: ActionFunction = async ( }); }; -const mark_read: ActionFunction = async (gmail, email) => { +const mark_read: ActionFunction = async ({ gmail, email }) => { return await markReadThread({ gmail, threadId: email.threadId, read: true }); }; -const track_thread: ActionFunction = async ( +const track_thread: ActionFunction = async ({ gmail, email, - _args, - userEmail, -) => { + emailAccountId, +}) => { await coordinateReplyProcess({ - emailAccountId: userEmail, + emailAccountId, threadId: email.threadId, messageId: email.id, sentAt: internalDateToDate(email.internalDate), diff --git a/apps/web/utils/ai/choose-rule/execute.ts b/apps/web/utils/ai/choose-rule/execute.ts index b75dc12c0..097edc218 100644 --- a/apps/web/utils/ai/choose-rule/execute.ts +++ b/apps/web/utils/ai/choose-rule/execute.ts @@ -23,12 +23,14 @@ export async function executeAct({ gmail, executedRule, userEmail, + emailAccountId, message, }: { gmail: gmail_v1.Gmail; executedRule: ExecutedRuleWithActionItems; message: ParsedMessage; userEmail: string; + emailAccountId: string; }) { const logger = createScopedLogger("ai-execute-act").with({ email: userEmail, @@ -50,13 +52,14 @@ export async function executeAct({ for (const action of executedRule.actionItems) { try { - const actionResult = await runActionFunction( + const actionResult = await runActionFunction({ gmail, - message, + email: message, action, userEmail, + emailAccountId, executedRule, - ); + }); if (action.type === ActionType.DRAFT_EMAIL && actionResult?.draftId) { await updateExecutedActionWithDraftId({ diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 4a280e1ae..70f55f36e 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -56,7 +56,7 @@ export async function runRules({ isTest, result, message, - email: emailAccount.email, + emailAccountId: emailAccount.id, }); logger.trace("Matching rule", { result }); @@ -121,6 +121,7 @@ async function executeMatchedRule( await executeAct({ gmail, userEmail: emailAccount.email, + emailAccountId: emailAccount.id, executedRule, message, }); @@ -262,12 +263,12 @@ async function analyzeSenderPatternIfAiMatch({ isTest, result, message, - email, + emailAccountId, }: { isTest: boolean; result: { rule?: Rule | null; matchReasons?: MatchReason[] }; message: ParsedMessage; - email: string; + emailAccountId: string; }) { if ( !isTest && @@ -285,7 +286,7 @@ async function analyzeSenderPatternIfAiMatch({ if (fromAddress) { after(() => analyzeSenderPattern({ - email, + emailAccountId, from: fromAddress, }), ); diff --git a/apps/web/utils/upstash/categorize-senders.ts b/apps/web/utils/upstash/categorize-senders.ts index a775d19ee..7cb3ddbbc 100644 --- a/apps/web/utils/upstash/categorize-senders.ts +++ b/apps/web/utils/upstash/categorize-senders.ts @@ -2,15 +2,16 @@ import chunk from "lodash/chunk"; import { deleteQueue, listQueues, publishToQstashQueue } from "@/utils/upstash"; import { env } from "@/env"; import type { AiCategorizeSenders } from "@/app/api/user/categorize/senders/batch/handle-batch-validation"; -import { hash } from "@/utils/hash"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("upstash"); const CATEGORIZE_SENDERS_PREFIX = "ai-categorize-senders"; -const getCategorizeSendersQueueName = ({ email }: { email: string }) => - `${CATEGORIZE_SENDERS_PREFIX}-${hash(email)}`; +const getCategorizeSendersQueueName = ({ + emailAccountId, +}: { emailAccountId: string }) => + `${CATEGORIZE_SENDERS_PREFIX}-${emailAccountId}`; /** * Publishes sender categorization tasks to QStash queue in batches @@ -26,7 +27,9 @@ export async function publishToAiCategorizeSendersQueue( const chunks = chunk(body.senders, BATCH_SIZE); // Create new queue for each user so we can run multiple users in parallel - const queueName = getCategorizeSendersQueueName({ email: body.email }); + const queueName = getCategorizeSendersQueueName({ + emailAccountId: body.emailAccountId, + }); logger.info("Publishing to AI categorize senders queue in chunks", { url, @@ -43,7 +46,7 @@ export async function publishToAiCategorizeSendersQueue( parallelism: 3, // Allow up to 3 concurrent jobs from this queue url, body: { - email: body.email, + emailAccountId: body.emailAccountId, senders: senderChunk, } satisfies AiCategorizeSenders, }), @@ -52,30 +55,32 @@ export async function publishToAiCategorizeSendersQueue( } export async function deleteEmptyCategorizeSendersQueues({ - skipEmail, + skipEmailAccountId, }: { - skipEmail: string; + skipEmailAccountId: string; }) { return deleteEmptyQueues({ prefix: CATEGORIZE_SENDERS_PREFIX, - skipEmail, + skipEmailAccountId, }); } async function deleteEmptyQueues({ prefix, - skipEmail, + skipEmailAccountId, }: { prefix: string; - skipEmail: string; + skipEmailAccountId: string; }) { const queues = await listQueues(); logger.info("Found queues", { count: queues.length }); for (const queue of queues) { if (!queue.name.startsWith(prefix)) continue; if ( - skipEmail && - queue.name === getCategorizeSendersQueueName({ email: skipEmail }) + skipEmailAccountId && + // TODO: + queue.name === + getCategorizeSendersQueueName({ emailAccountId: skipEmailAccountId }) ) continue; From da327c5b4abdf674b64a047f42a06873dccd48dd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 02:16:07 +0300 Subject: [PATCH 080/176] fix tests --- apps/web/__tests__/ai-choose-args.test.ts | 12 +- apps/web/__tests__/ai-choose-rule.test.ts | 32 ++--- apps/web/__tests__/ai-create-group.test.ts | 6 +- .../ai-detect-recurring-pattern.test.ts | 14 +- apps/web/__tests__/ai-diff-rules.test.ts | 17 ++- apps/web/__tests__/ai-example-matches.test.ts | 6 +- .../ai-extract-from-email-history.test.ts | 10 +- .../__tests__/ai-extract-knowledge.test.ts | 19 ++- apps/web/__tests__/ai-find-snippets.test.ts | 7 +- .../__tests__/ai-process-user-request.test.ts | 16 +-- apps/web/__tests__/ai-rule-fix.test.ts | 11 +- .../ai/reply/draft-with-knowledge.test.ts | 23 +--- apps/web/__tests__/helpers.ts | 12 +- apps/web/__tests__/writing-style.test.ts | 6 +- .../utils/ai/choose-rule/match-rules.test.ts | 128 ++++++++++++++---- 15 files changed, 193 insertions(+), 126 deletions(-) diff --git a/apps/web/__tests__/ai-choose-args.test.ts b/apps/web/__tests__/ai-choose-args.test.ts index 865a93ecb..1381ba914 100644 --- a/apps/web/__tests__/ai-choose-args.test.ts +++ b/apps/web/__tests__/ai-choose-args.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { type Action, ActionType, LogicalOperator } from "@prisma/client"; import type { ParsedMessage, RuleWithActions } from "@/utils/types"; import { getActionItemsWithAiArgs } from "@/utils/ai/choose-rule/choose-args"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-choose-args @@ -20,7 +20,7 @@ describe.runIf(isAiTest)("getActionItemsWithAiArgs", () => { subject: "Test subject", content: "Test content", }), - user: getUser(), + emailAccount: getEmailAccount(), selectedRule: rule, gmail: {} as any, }); @@ -42,7 +42,7 @@ describe.runIf(isAiTest)("getActionItemsWithAiArgs", () => { subject: "Quick question", content: "When is the meeting tomorrow?", }), - user: getUser(), + emailAccount: getEmailAccount(), selectedRule: rule, gmail: {} as any, }); @@ -69,7 +69,7 @@ describe.runIf(isAiTest)("getActionItemsWithAiArgs", () => { subject: "Quick question", content: "How much are pears?", }), - user: getUser(), + emailAccount: getEmailAccount(), selectedRule: rule, gmail: {} as any, }); @@ -96,7 +96,7 @@ describe.runIf(isAiTest)("getActionItemsWithAiArgs", () => { subject: "Project status", content: "Can you update me on the project status?", }), - user: getUser(), + emailAccount: getEmailAccount(), selectedRule: rule, gmail: {} as any, }); @@ -133,7 +133,7 @@ Matt`, subject: "fruits", content: "how much do apples cost?", }), - user: getUser(), + emailAccount: getEmailAccount(), selectedRule: rule, gmail: {} as any, }); diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index 421c78c48..2baa6c492 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { type Action, ActionType, LogicalOperator } from "@prisma/client"; import { defaultReplyTrackerInstructions } from "@/utils/reply-tracker/consts"; -import { getEmail, getUser } from "@/__tests__/helpers"; +import { getEmail, getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-choose-rule @@ -15,7 +15,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { const result = await aiChooseRule({ rules: [], email: getEmail(), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ reason: "No rules" }); @@ -29,7 +29,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { const result = await aiChooseRule({ email: getEmail({ subject: "test" }), rules: [rule], - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -49,7 +49,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { const result = await aiChooseRule({ rules: [rule1, rule2], email: getEmail({ subject: "remember that call" }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -85,7 +85,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Joke", content: "Tell me a joke about sheep", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -153,7 +153,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Can we meet for lunch tomorrow?", content: "LMK\n\n--\nAlice Smith,\nCEO, The Boring Fund", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -170,7 +170,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { content: "We're experiencing critical server issues affecting production.", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -186,7 +186,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Your invoice for March 2024", content: "Please find attached your invoice for services rendered.", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -203,7 +203,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { content: "I came across your profile and think you'd be perfect for...", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -219,7 +219,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Please review: Contract for new project", content: "Attached is the contract for your review and signature.", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -235,7 +235,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Team lunch tomorrow?", content: "Would you like to join us for team lunch tomorrow at 12pm?", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -251,7 +251,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "New Feature Release: AI Integration", content: "We're excited to announce our new AI features...", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -267,7 +267,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "50% off Spring Sale!", content: "Don't miss out on our biggest sale of the season!", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -283,7 +283,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Weekly Team Update", content: "Here's what the team accomplished this week...", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -299,7 +299,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Customer Feedback: App Performance", content: "I've been experiencing slow loading times...", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ @@ -315,7 +315,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { subject: "Invitation: Annual Tech Conference", content: "You're invited to speak at our annual conference...", }), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toEqual({ diff --git a/apps/web/__tests__/ai-create-group.test.ts b/apps/web/__tests__/ai-create-group.test.ts index 5e34e30e7..2ae65ea39 100644 --- a/apps/web/__tests__/ai-create-group.test.ts +++ b/apps/web/__tests__/ai-create-group.test.ts @@ -3,7 +3,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { aiGenerateGroupItems } from "@/utils/ai/group/create-group"; import { queryBatchMessages } from "@/utils/gmail/message"; import type { ParsedMessage } from "@/utils/types"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-create-group @@ -16,7 +16,7 @@ vi.mock("@/utils/gmail/message", () => ({ describe.runIf(isAiTest)("aiGenerateGroupItems", () => { it("should generate group items based on user prompt", async () => { - const user = getUser(); + const emailAccount = getEmailAccount(); const gmail = {} as gmail_v1.Gmail; const group = { name: "Work Emails", @@ -54,7 +54,7 @@ describe.runIf(isAiTest)("aiGenerateGroupItems", () => { nextPageToken: null, }); - const result = await aiGenerateGroupItems(user, gmail, group); + const result = await aiGenerateGroupItems(emailAccount, gmail, group); expect(result).toEqual({ senders: expect.arrayContaining(["@mycompany.com"]), diff --git a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts index 374a24692..909fdb49a 100644 --- a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts +++ b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiDetectRecurringPattern } from "@/utils/ai/choose-rule/ai-detect-recurring-pattern"; import type { EmailForLLM } from "@/utils/types"; import { RuleName } from "@/utils/rule/consts"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // Run with: pnpm test-ai ai-detect-recurring-pattern @@ -221,7 +221,7 @@ Only flag when someone: test("detects newsletter pattern and suggests Newsletter rule", async () => { const result = await aiDetectRecurringPattern({ emails: getNewsletterEmails(), - user: getUser(), + emailAccount: getEmailAccount(), rules: getRealisticRules(), }); @@ -234,7 +234,7 @@ Only flag when someone: test("detects receipt pattern and suggests Receipt rule", async () => { const result = await aiDetectRecurringPattern({ emails: getReceiptEmails(), - user: getUser(), + emailAccount: getEmailAccount(), rules: getRealisticRules(), }); @@ -247,7 +247,7 @@ Only flag when someone: test("detects calendar pattern and suggests Calendar rule", async () => { const result = await aiDetectRecurringPattern({ emails: getCalendarEmails(), - user: getUser(), + emailAccount: getEmailAccount(), rules: getRealisticRules(), }); @@ -260,7 +260,7 @@ Only flag when someone: test("detects reply needed pattern and suggests To Reply rule", async () => { const result = await aiDetectRecurringPattern({ emails: getNeedsReplyEmails(), - user: getUser(), + emailAccount: getEmailAccount(), rules: getRealisticRules(), }); @@ -273,7 +273,7 @@ Only flag when someone: test("returns null for mixed inconsistent emails", async () => { const result = await aiDetectRecurringPattern({ emails: getMixedInconsistentEmails(), - user: getUser(), + emailAccount: getEmailAccount(), rules: getRealisticRules(), }); @@ -285,7 +285,7 @@ Only flag when someone: test("returns null or matches Notification rule for same sender but different types of content", async () => { const result = await aiDetectRecurringPattern({ emails: getDifferentContentEmails(), - user: getUser(), + emailAccount: getEmailAccount(), rules: getRealisticRules(), }); diff --git a/apps/web/__tests__/ai-diff-rules.test.ts b/apps/web/__tests__/ai-diff-rules.test.ts index 55aa648cb..71c50f66a 100644 --- a/apps/web/__tests__/ai-diff-rules.test.ts +++ b/apps/web/__tests__/ai-diff-rules.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { aiDiffRules } from "@/utils/ai/rule/diff-rules"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-diff-rules @@ -10,7 +10,7 @@ vi.mock("server-only", () => ({})); describe.runIf(isAiTest)("aiDiffRules", () => { it("should correctly identify added, edited, and removed rules", async () => { - const user = getUser(); + const emailAccount = getEmailAccount(); const oldPromptFile = ` * Label receipts as "Receipt" @@ -26,7 +26,11 @@ describe.runIf(isAiTest)("aiDiffRules", () => { * Label all emails from support@company.com as "Support" `.trim(); - const result = await aiDiffRules({ user, oldPromptFile, newPromptFile }); + const result = await aiDiffRules({ + emailAccount, + oldPromptFile, + newPromptFile, + }); expect(result).toEqual({ addedRules: ['Label all emails from support@company.com as "Support"'], @@ -41,12 +45,15 @@ describe.runIf(isAiTest)("aiDiffRules", () => { }, 15_000); it("should handle errors gracefully", async () => { - const user = { ...getUser(), aiApiKey: "invalid-api-key" }; + const emailAccount = { + ...getEmailAccount(), + user: { ...getEmailAccount().user, aiApiKey: "invalid-api-key" }, + }; const oldPromptFile = "Some old prompt"; const newPromptFile = "Some new prompt"; await expect( - aiDiffRules({ user, oldPromptFile, newPromptFile }), + aiDiffRules({ emailAccount, oldPromptFile, newPromptFile }), ).rejects.toThrow(); }); }); diff --git a/apps/web/__tests__/ai-example-matches.test.ts b/apps/web/__tests__/ai-example-matches.test.ts index 38fbf7464..9814d0cd2 100644 --- a/apps/web/__tests__/ai-example-matches.test.ts +++ b/apps/web/__tests__/ai-example-matches.test.ts @@ -4,7 +4,7 @@ import { aiFindExampleMatches } from "@/utils/ai/example-matches/find-example-ma import { queryBatchMessages } from "@/utils/gmail/message"; import type { ParsedMessage } from "@/utils/types"; import { findExampleMatchesSchema } from "@/utils/ai/example-matches/find-example-matches"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-find-example-matches @@ -17,7 +17,7 @@ vi.mock("@/utils/gmail/message", () => ({ describe.runIf(isAiTest)("aiFindExampleMatches", () => { it("should find example matches based on user prompt", async () => { - const user = getUser(); + const emailAccount = getEmailAccount(); const gmail = {} as gmail_v1.Gmail; const rulesPrompt = ` @@ -61,7 +61,7 @@ describe.runIf(isAiTest)("aiFindExampleMatches", () => { nextPageToken: null, }); - const result = await aiFindExampleMatches(user, gmail, rulesPrompt); + const result = await aiFindExampleMatches(emailAccount, gmail, rulesPrompt); expect(result).toEqual( expect.objectContaining({ diff --git a/apps/web/__tests__/ai-extract-from-email-history.test.ts b/apps/web/__tests__/ai-extract-from-email-history.test.ts index 7b2377cad..2f4295b29 100644 --- a/apps/web/__tests__/ai-extract-from-email-history.test.ts +++ b/apps/web/__tests__/ai-extract-from-email-history.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiExtractFromEmailHistory } from "@/utils/ai/knowledge/extract-from-email-history"; import type { EmailForLLM } from "@/utils/types"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai extract-from-email-history @@ -39,12 +39,12 @@ describe.runIf(isAiTest)("aiExtractFromEmailHistory", () => { test("successfully extracts information from email thread", async () => { const messages = getTestMessages(); - const user = getUser(); + const emailAccount = getEmailAccount(); const result = await aiExtractFromEmailHistory({ currentThreadMessages: messages.slice(0, 1), historicalMessages: messages.slice(1), - user, + emailAccount, }); expect(result).toBeDefined(); @@ -61,7 +61,7 @@ describe.runIf(isAiTest)("aiExtractFromEmailHistory", () => { const result = await aiExtractFromEmailHistory({ currentThreadMessages: currentMessages, historicalMessages: [], - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toBeDefined(); @@ -79,7 +79,7 @@ describe.runIf(isAiTest)("aiExtractFromEmailHistory", () => { const result = await aiExtractFromEmailHistory({ currentThreadMessages: currentMessages, historicalMessages: historicalMessages, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toBeDefined(); diff --git a/apps/web/__tests__/ai-extract-knowledge.test.ts b/apps/web/__tests__/ai-extract-knowledge.test.ts index 134216bff..f900b4c35 100644 --- a/apps/web/__tests__/ai-extract-knowledge.test.ts +++ b/apps/web/__tests__/ai-extract-knowledge.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiExtractRelevantKnowledge } from "@/utils/ai/knowledge/extract"; import type { Knowledge } from "@prisma/client"; -import type { UserEmailWithAI } from "@/utils/llms/types"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-extract-knowledge @@ -93,7 +92,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: getKnowledgeBase(), emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBeDefined(); @@ -112,7 +111,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: getKnowledgeBase(), emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBeDefined(); @@ -131,7 +130,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: getKnowledgeBase(), emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBeDefined(); @@ -149,7 +148,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: [], emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBe(""); @@ -162,7 +161,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: getKnowledgeBase(), emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBeDefined(); @@ -182,7 +181,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: getKnowledgeBase(), emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBeDefined(); @@ -201,7 +200,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: getKnowledgeBase(), emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBeDefined(); @@ -220,7 +219,7 @@ describe.runIf(isAiTest)("aiExtractRelevantKnowledge", () => { const result = await aiExtractRelevantKnowledge({ knowledgeBase: getKnowledgeBase(), emailContent, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result?.relevantContent).toBeDefined(); diff --git a/apps/web/__tests__/ai-find-snippets.test.ts b/apps/web/__tests__/ai-find-snippets.test.ts index fbc83c526..425dc4dd2 100644 --- a/apps/web/__tests__/ai-find-snippets.test.ts +++ b/apps/web/__tests__/ai-find-snippets.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test, vi } from "vitest"; import { aiFindSnippets } from "@/utils/ai/snippets/find-snippets"; -import type { EmailForLLM } from "@/utils/types"; -import { getEmail, getUser } from "@/__tests__/helpers"; +import { getEmail, getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-find-snippets const isAiTest = process.env.RUN_AI_TESTS === "true"; @@ -27,7 +26,7 @@ describe.runIf(isAiTest)("aiFindSnippets", () => { const result = await aiFindSnippets({ sentEmails: emails, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result.snippets).toHaveLength(1); @@ -58,7 +57,7 @@ describe.runIf(isAiTest)("aiFindSnippets", () => { const result = await aiFindSnippets({ sentEmails: emails, - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result.snippets).toHaveLength(0); diff --git a/apps/web/__tests__/ai-process-user-request.test.ts b/apps/web/__tests__/ai-process-user-request.test.ts index df57a0d0b..fb643957a 100644 --- a/apps/web/__tests__/ai-process-user-request.test.ts +++ b/apps/web/__tests__/ai-process-user-request.test.ts @@ -5,7 +5,7 @@ import type { ParsedMessage, ParsedMessageHeaders } from "@/utils/types"; import type { RuleWithRelations } from "@/utils/ai/rule/create-prompt-from-rule"; import type { Category, GroupItem, Prisma } from "@prisma/client"; import { GroupItemType, LogicalOperator } from "@prisma/client"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-process-user-request @@ -46,7 +46,7 @@ describe( }); const result = await processUserRequest({ - user: getUser(), + emailAccount: getEmailAccount(), rules: [rule], messages: [ { @@ -99,7 +99,7 @@ describe( }); const result = await processUserRequest({ - user: getUser(), + emailAccount: getEmailAccount(), rules: [ruleSupport, ruleUrgent], messages: [ { @@ -144,7 +144,7 @@ describe( }); const result = await processUserRequest({ - user: getUser(), + emailAccount: getEmailAccount(), rules: [rule], messages: [ { @@ -219,7 +219,7 @@ describe( }); const result = await processUserRequest({ - user: getUser(), + emailAccount: getEmailAccount(), rules: [rule], messages: [ { @@ -281,7 +281,7 @@ describe( }); const result = await processUserRequest({ - user: getUser(), + emailAccount: getEmailAccount(), rules: [rule], messages: [ { @@ -326,7 +326,7 @@ describe( }); const result = await processUserRequest({ - user: getUser(), + emailAccount: getEmailAccount(), rules: [rule], messages: [ { @@ -377,7 +377,7 @@ describe( }); const result = await processUserRequest({ - user: getUser(), + emailAccount: getEmailAccount(), rules: [rule], messages: [ { diff --git a/apps/web/__tests__/ai-rule-fix.test.ts b/apps/web/__tests__/ai-rule-fix.test.ts index 2997e3d7c..455973848 100644 --- a/apps/web/__tests__/ai-rule-fix.test.ts +++ b/apps/web/__tests__/ai-rule-fix.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import stripIndent from "strip-indent"; import { aiRuleFix } from "@/utils/ai/rule/rule-fix"; -import type { EmailForLLM } from "@/utils/types"; -import { getEmail, getUser } from "@/__tests__/helpers"; +import { getEmail, getEmailAccount } from "@/__tests__/helpers"; // pnpm test-ai ai-rule-fix @@ -36,7 +35,7 @@ describe.runIf(isAiTest)("aiRuleFix", () => { actualRule: rule, expectedRule: null, email: salesEmail, - user: getUser(), + emailAccount: getEmailAccount(), }); console.log(result); @@ -76,7 +75,7 @@ describe.runIf(isAiTest)("aiRuleFix", () => { actualRule, expectedRule, email: feedbackEmail, - user: getUser(), + emailAccount: getEmailAccount(), }); console.log(result); @@ -112,7 +111,7 @@ describe.runIf(isAiTest)("aiRuleFix", () => { actualRule, expectedRule: null, email: newsletterEmail, - user: getUser(), + emailAccount: getEmailAccount(), }); console.log(result); @@ -148,7 +147,7 @@ describe.runIf(isAiTest)("aiRuleFix", () => { actualRule: null, expectedRule: correctRule, email: priceRequestEmail, - user: getUser(), + emailAccount: getEmailAccount(), }); console.log(result); diff --git a/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts b/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts index 410112000..3f62d07f3 100644 --- a/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts +++ b/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, vi } from "vitest"; import { aiDraftWithKnowledge } from "@/utils/ai/reply/draft-with-knowledge"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM } from "@/utils/types"; +import { getEmailAccount } from "@/__tests__/helpers"; // Run with: pnpm test-ai draft-with-knowledge @@ -14,14 +15,14 @@ describe.runIf(isAiTest)("aiDraftWithKnowledge", () => { test( "successfully drafts a reply with knowledge and history", async () => { - const user = getUser(); + const emailAccount = getEmailAccount(); const messages = getMessages(2); const knowledgeBaseContent = "Relevant knowledge point."; const emailHistorySummary = "Previous interaction summary."; const result = await aiDraftWithKnowledge({ messages, - user, + emailAccount, knowledgeBaseContent, emailHistorySummary, writingStyle: null, @@ -40,12 +41,12 @@ describe.runIf(isAiTest)("aiDraftWithKnowledge", () => { test( "successfully drafts a reply without knowledge or history", async () => { - const user = getUser({ about: null }); + const emailAccount = getEmailAccount(); const messages = getMessages(1); const result = await aiDraftWithKnowledge({ messages, - user, + emailAccount, knowledgeBaseContent: null, emailHistorySummary: null, writingStyle: null, @@ -62,18 +63,6 @@ describe.runIf(isAiTest)("aiDraftWithKnowledge", () => { ); }); -function getUser(overrides: Partial = {}): UserEmailWithAI { - return { - userId: "user-123", - email: "user@example.com", - aiModel: null, - aiProvider: null, - aiApiKey: null, - about: "I am a user.", - ...overrides, - }; -} - type TestMessage = EmailForLLM & { to: string }; function getMessages(count = 1): TestMessage[] { diff --git a/apps/web/__tests__/helpers.ts b/apps/web/__tests__/helpers.ts index 4c12a197a..7013e4424 100644 --- a/apps/web/__tests__/helpers.ts +++ b/apps/web/__tests__/helpers.ts @@ -1,13 +1,17 @@ +import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM } from "@/utils/types"; -export function getUser() { +export function getEmailAccount(): EmailAccountWithAI { return { + id: "email-account-id", userId: "user1", email: "user@test.com", - aiModel: null, - aiProvider: null, - aiApiKey: null, about: null, + user: { + aiModel: null, + aiProvider: null, + aiApiKey: null, + }, }; } diff --git a/apps/web/__tests__/writing-style.test.ts b/apps/web/__tests__/writing-style.test.ts index 6be330972..3fd05c57c 100644 --- a/apps/web/__tests__/writing-style.test.ts +++ b/apps/web/__tests__/writing-style.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiAnalyzeWritingStyle } from "@/utils/ai/knowledge/writing-style"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // Run with: pnpm test-ai writing-style @@ -19,7 +19,7 @@ describe.runIf(isAiTest)( test("successfully analyzes writing style from emails", async () => { const result = await aiAnalyzeWritingStyle({ emails: getTestEmails(), - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toHaveProperty("typicalLength"); @@ -32,7 +32,7 @@ describe.runIf(isAiTest)( test("handles empty emails array gracefully", async () => { const result = await aiAnalyzeWritingStyle({ emails: [], - user: getUser(), + emailAccount: getEmailAccount(), }); expect(result).toBeNull(); diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index ce96a73c0..5bc841905 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -17,7 +17,7 @@ import type { } from "@/utils/types"; import prisma from "@/utils/__mocks__/prisma"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; -import { getUser } from "@/__tests__/helpers"; +import { getEmailAccount } from "@/__tests__/helpers"; // Run with: // pnpm test match-rules.test.ts @@ -107,8 +107,13 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), }); - const user = getUser(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const emailAccount = getEmailAccount(); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe("Matched static conditions"); @@ -120,9 +125,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe("Matched static conditions"); @@ -134,9 +144,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -159,9 +174,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); @@ -178,9 +198,14 @@ describe("findMatchingRule", () => { }); const rules = [rule]; const message = getMessage(); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -197,9 +222,14 @@ describe("findMatchingRule", () => { }); const rules = [rule]; const message = getMessage(); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -233,9 +263,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); @@ -263,9 +298,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "ai@newsletter.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBeDefined(); @@ -294,9 +334,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "marketing@newsletter.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule).toBeUndefined(); expect(result.reason).toBeDefined(); @@ -316,9 +361,14 @@ describe("findMatchingRule", () => { }); const rules = [rule]; const message = getMessage(); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -337,9 +387,14 @@ describe("findMatchingRule", () => { }); const rules = [rule]; const message = getMessage(); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toBe('Matched category: "category"'); @@ -379,9 +434,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), // This matches item in wrongGroup }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule).toBeUndefined(); expect(result.reason).toBeUndefined(); @@ -419,9 +479,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); expect(result.rule?.id).toBe(rule.id); expect(result.reason).toContain("test@example.com"); @@ -460,9 +525,14 @@ describe("findMatchingRule", () => { const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), }); - const user = getUser(); + const emailAccount = getEmailAccount(); - const result = await findMatchingRule({ rules, message, user, gmail }); + const result = await findMatchingRule({ + rules, + message, + emailAccount, + gmail, + }); // Should match the first rule only expect(result.rule?.id).toBe("rule1"); From 22dd9817ea601b4edfdc974fabfea4fce7658aec Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 02:17:20 +0300 Subject: [PATCH 081/176] fix ts --- apps/web/utils/actions/ai-rule.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 9946a776c..90914e62f 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -162,7 +162,7 @@ export const createAutomationAction = actionClient .action(async ({ ctx: { emailAccountId }, parsedInput: { prompt } }) => { const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new Error("Email account not found"); let result: CreateOrUpdateRuleSchemaWithCategories; @@ -170,12 +170,12 @@ export const createAutomationAction = actionClient result = await aiCreateRule(prompt, emailAccount); } catch (error) { if (error instanceof Error) { - return { error: `AI error creating rule. ${error.message}` }; + throw new Error(`AI error creating rule. ${error.message}`); } - return { error: "AI error creating rule." }; + throw new Error("AI error creating rule."); } - if (!result) return { error: "AI error creating rule." }; + if (!result) throw new Error("AI error creating rule."); const createdRule = await safeCreateRule({ result, From ed25c5aecfd556916792c324c15bd43d90cdadf8 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 02:21:51 +0300 Subject: [PATCH 082/176] fixes --- .../automation/onboarding/CategoriesSetup.tsx | 6 ++++-- .../automation/onboarding/page.tsx | 15 +++++++++------ .../(app)/[emailAccountId]/automation/page.tsx | 4 ++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/automation/onboarding/CategoriesSetup.tsx b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/CategoriesSetup.tsx index b42662bcc..841bb1533 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/onboarding/CategoriesSetup.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/CategoriesSetup.tsx @@ -40,8 +40,10 @@ import { const NEXT_URL = "/automation/onboarding/draft-replies"; export function CategoriesSetup({ + emailAccountId, defaultValues, }: { + emailAccountId: string; defaultValues?: Partial; }) { const router = useRouter(); @@ -62,10 +64,10 @@ export function CategoriesSetup({ const onSubmit = useCallback( async (data: CreateRulesOnboardingBody) => { // runs in background so we can move on to next step faster - createRulesOnboardingAction(data); + createRulesOnboardingAction(emailAccountId, data); router.push(NEXT_URL); }, - [router], + [emailAccountId, router], ); return ( diff --git a/apps/web/app/(app)/[emailAccountId]/automation/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/page.tsx index d73c1753e..a650285e5 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/page.tsx @@ -14,12 +14,15 @@ export default async function OnboardingPage({ }: { params: Promise<{ emailAccountId: string }>; }) { - const { account } = await params; - const defaultValues = await getUserPreferences({ accountId: account }); + const { emailAccountId } = await params; + const defaultValues = await getUserPreferences({ emailAccountId }); return ( - + ); } @@ -39,12 +42,12 @@ type UserPreferences = Prisma.EmailAccountGetPayload<{ }>; async function getUserPreferences({ - accountId, + emailAccountId, }: { - accountId: string; + emailAccountId: string; }) { const emailAccount = await prisma.emailAccount.findUnique({ - where: { accountId }, + where: { id: emailAccountId }, select: { rules: { select: { diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index 9284af7c0..585aa72d1 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -25,7 +25,7 @@ export default async function AutomationPage({ }: { params: Promise<{ emailAccountId: string }>; }) { - const { account } = await params; + const { emailAccountId } = await params; // onboarding redirect const cookieStore = await cookies(); @@ -34,7 +34,7 @@ export default async function AutomationPage({ if (!viewedOnboarding) { const hasRule = await prisma.rule.findFirst({ - where: { emailAccount: { accountId: account } }, + where: { emailAccountId }, select: { id: true }, }); From a32013e93f625aae207b99ac1e763edb309b378a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 02:24:05 +0300 Subject: [PATCH 083/176] fixes --- .cursor/rules/form-handling.mdc | 6 +++--- apps/web/providers/GlobalProviders.tsx | 6 +++--- apps/web/utils/actions/ai-rule.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.cursor/rules/form-handling.mdc b/.cursor/rules/form-handling.mdc index 2644790b0..70c136d59 100644 --- a/.cursor/rules/form-handling.mdc +++ b/.cursor/rules/form-handling.mdc @@ -18,18 +18,18 @@ import { toastSuccess, toastError } from "@/components/Toast"; import { createExampleAction } from "@/utils/actions/example"; import { type CreateExampleBody } from "@/utils/actions/example.validation"; -export const ExampleForm = () => { +export const ExampleForm = ({ emailAccountId }: { emailAccountId: string }) => { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ - resolver: zodResolver(processHistorySchema), + resolver: zodResolver(schema), }); const onSubmit: SubmitHandler = useCallback( async (data) => { - const result = await createExampleAction(data.email); + const result = await createExampleAction(emailAccountId, data); if (result?.serverError) { toastError({ title: "Error", description: result.serverError }); diff --git a/apps/web/providers/GlobalProviders.tsx b/apps/web/providers/GlobalProviders.tsx index 9d07452d7..6051b4c79 100644 --- a/apps/web/providers/GlobalProviders.tsx +++ b/apps/web/providers/GlobalProviders.tsx @@ -4,12 +4,12 @@ import { SessionProvider } from "@/providers/SessionProvider"; import { SWRProvider } from "@/providers/SWRProvider"; import { StatLoaderProvider } from "@/providers/StatLoaderProvider"; import { ComposeModalProvider } from "@/providers/ComposeModalProvider"; -import { AccountProvider } from "@/providers/EmailAccountProvider"; +import { EmailAccountProvider } from "@/providers/EmailAccountProvider"; export function GlobalProviders(props: { children: React.ReactNode }) { return ( - + @@ -17,7 +17,7 @@ export function GlobalProviders(props: { children: React.ReactNode }) { - + ); } diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 90914e62f..55de2bf6d 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -44,10 +44,10 @@ export const runRulesAction = actionClient async ({ ctx: { emailAccountId }, parsedInput: { messageId, threadId, rerun, isTest }, - }): Promise => { + }): Promise => { const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new Error("Email account not found"); const gmail = await getGmailClientForEmail({ emailAccountId }); From 865fc6fe62a2268744cf28e609735daa0292ff2c Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 02:43:08 +0300 Subject: [PATCH 084/176] more fixes --- .../[emailAccountId]/PermissionsCheck.tsx | 2 +- .../web/app/(app)/[emailAccountId]/assess.tsx | 8 ++--- .../[emailAccountId]/automation/History.tsx | 2 +- .../[emailAccountId]/automation/Pending.tsx | 20 +++++------- .../automation/ProcessRules.tsx | 6 ++-- .../automation/ReportMistake.tsx | 16 ++++++---- .../[emailAccountId]/automation/RuleForm.tsx | 32 +++++++++++-------- .../[emailAccountId]/automation/Rules.tsx | 2 +- .../automation/RulesPrompt.tsx | 2 +- .../automation/TestCustomEmailForm.tsx | 2 +- .../automation/create/page.tsx | 2 +- .../automation/group/ViewGroup.tsx | 4 +-- .../automation/knowledge/KnowledgeBase.tsx | 2 +- .../automation/knowledge/KnowledgeForm.tsx | 2 +- .../onboarding/draft-replies/page.tsx | 2 +- .../rule/[ruleId]/examples/example-list.tsx | 2 +- .../BulkUnsubscribeDesktop.tsx | 1 + .../BulkUnsubscribeSection.tsx | 3 +- .../bulk-unsubscribe/common.tsx | 9 ++++-- .../bulk-unsubscribe/hooks.ts | 27 +++++++++------- .../(app)/[emailAccountId]/clean/CleanRun.tsx | 3 -- .../clean/ConfirmationStep.tsx | 2 +- .../[emailAccountId]/clean/EmailFirehose.tsx | 6 ++-- .../clean/EmailFirehoseItem.tsx | 12 ++++--- .../[emailAccountId]/clean/PreviewBatch.tsx | 2 +- .../(app)/[emailAccountId]/clean/run/page.tsx | 3 +- .../cold-email-blocker/ColdEmailList.tsx | 2 +- .../ColdEmailPromptForm.tsx | 2 +- .../cold-email-blocker/ColdEmailRejected.tsx | 2 +- .../cold-email-blocker/ColdEmailSettings.tsx | 2 +- .../cold-email-blocker/TestRules.tsx | 6 ++-- .../compose/ComposeEmailForm.tsx | 2 +- .../[emailAccountId]/debug/drafts/page.tsx | 2 +- .../reply-zero/EnableReplyTracker.tsx | 2 +- .../settings/AboutSectionForm.tsx | 2 +- .../settings/DeleteSection.tsx | 2 +- .../settings/LabelsSection.tsx | 4 +-- .../settings/SignatureSectionForm.tsx | 2 +- .../settings/WebhookGenerate.tsx | 2 +- .../[emailAccountId]/simple/SimpleList.tsx | 2 +- .../CategorizeWithAiButton.tsx | 2 +- .../smart-categories/CreateCategoryButton.tsx | 2 +- .../smart-categories/Uncategorized.tsx | 18 ++++------- .../[emailAccountId]/stats/EmailAnalytics.tsx | 2 +- .../stats/NewsletterModal.tsx | 2 +- .../onboarding/OnboardingEmailAssistant.tsx | 2 +- apps/web/components/ActionButtons.tsx | 2 +- apps/web/components/CategorySelect.tsx | 4 ++- apps/web/components/GroupedTable.tsx | 15 +++++---- apps/web/components/email-list/EmailList.tsx | 10 +++--- .../components/email-list/EmailMessage.tsx | 2 +- .../web/components/email-list/PlanActions.tsx | 2 +- apps/web/providers/EmailAccountProvider.tsx | 20 +++++++----- apps/web/store/QueueInitializer.tsx | 2 +- apps/web/store/ai-categorize-sender-queue.ts | 12 +++---- apps/web/utils/actions/client.ts | 6 ++-- apps/web/utils/queue/email-actions.ts | 8 +++-- 57 files changed, 170 insertions(+), 149 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx index 731d4a61a..208a8e846 100644 --- a/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx +++ b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx @@ -9,7 +9,7 @@ const permissionsChecked: Record = {}; export function PermissionsCheck() { const router = useRouter(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); useEffect(() => { if (permissionsChecked[email]) return; diff --git a/apps/web/app/(app)/[emailAccountId]/assess.tsx b/apps/web/app/(app)/[emailAccountId]/assess.tsx index 828fca50e..0495a536b 100644 --- a/apps/web/app/(app)/[emailAccountId]/assess.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assess.tsx @@ -10,15 +10,15 @@ import { import { useAccount } from "@/providers/EmailAccountProvider"; export function AssessUser() { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync: executeAssessAsync } = useAction( - assessAction.bind(null, email), + assessAction.bind(null, emailAccountId), ); const { execute: executeWhitelistInboxZero } = useAction( - whitelistInboxZeroAction.bind(null, email), + whitelistInboxZeroAction.bind(null, emailAccountId), ); const { execute: executeAnalyzeWritingStyle } = useAction( - analyzeWritingStyleAction.bind(null, email), + analyzeWritingStyleAction.bind(null, emailAccountId), ); // biome-ignore lint/correctness/useExhaustiveDependencies: only run once diff --git a/apps/web/app/(app)/[emailAccountId]/automation/History.tsx b/apps/web/app/(app)/[emailAccountId]/automation/History.tsx index 799395b3e..3522140ef 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/History.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/History.tsx @@ -35,7 +35,7 @@ export function History() { const { data, isLoading, error } = useSWR( `/api/user/planned/history?page=${page}&ruleId=${ruleId}`, ); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); return ( <> diff --git a/apps/web/app/(app)/[emailAccountId]/automation/Pending.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Pending.tsx index e6734d0b6..e73146929 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/Pending.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Pending.tsx @@ -42,8 +42,6 @@ export function Pending() { `/api/user/planned?page=${page}&ruleId=${ruleId}`, ); - const { email } = useAccount(); - return ( <>
@@ -55,7 +53,6 @@ export function Pending() { ) : ( @@ -70,14 +67,13 @@ export function Pending() { function PendingTable({ pending, totalPages, - userEmail, mutate, }: { pending: PendingExecutedRules["executedRules"]; totalPages: number; - userEmail: string; mutate: () => void; }) { + const { emailAccountId, userEmail } = useAccount(); const { selected, isAllSelected, onToggleSelect, onToggleSelectAll } = useToggleSelect(pending); @@ -89,7 +85,7 @@ function PendingTable({ for (const id of Array.from(selected.keys())) { const p = pending.find((p) => p.id === id); if (!p) continue; - const result = await approvePlanAction(userEmail, { + const result = await approvePlanAction(emailAccountId, { executedRuleId: id, message: p.message, }); @@ -101,13 +97,13 @@ function PendingTable({ mutate(); } setIsApproving(false); - }, [selected, pending, mutate, userEmail]); + }, [selected, pending, mutate, emailAccountId]); const rejectSelected = useCallback(async () => { setIsRejecting(true); for (const id of Array.from(selected.keys())) { const p = pending.find((p) => p.id === id); if (!p) continue; - const result = await rejectPlanAction(userEmail, { + const result = await rejectPlanAction(emailAccountId, { executedRuleId: id, }); if (result?.serverError) { @@ -118,7 +114,7 @@ function PendingTable({ mutate(); } setIsRejecting(false); - }, [selected, pending, mutate, userEmail]); + }, [selected, pending, mutate, emailAccountId]); return (
@@ -224,7 +220,7 @@ function ExecuteButtons({ }) { const [isApproving, setIsApproving] = useState(false); const [isRejecting, setIsRejecting] = useState(false); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); return (
@@ -232,7 +228,7 @@ function ExecuteButtons({ variant="default" onClick={async () => { setIsApproving(true); - const result = await approvePlanAction(email, { + const result = await approvePlanAction(emailAccountId, { executedRuleId: id, message, }); @@ -255,7 +251,7 @@ function ExecuteButtons({ variant="outline" onClick={async () => { setIsRejecting(true); - const result = await rejectPlanAction(email, { + const result = await rejectPlanAction(emailAccountId, { executedRuleId: id, }); if (result?.serverError) { diff --git a/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx index 004b45b68..e45498319 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx @@ -76,7 +76,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { }, [data]); const { data: rules } = useSWR("/api/user/rules"); - const { email: userEmail } = useAccount(); + const { emailAccountId, userEmail } = useAccount(); // only show test rules form if we have an AI rule. this form won't match group/static rules which will confuse users const hasAiRules = rules?.some( @@ -98,7 +98,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { async (message: Message, rerun?: boolean) => { setIsRunning((prev) => ({ ...prev, [message.id]: true })); - const result = await runRulesAction(userEmail, { + const result = await runRulesAction(emailAccountId, { messageId: message.id, threadId: message.threadId, isTest: testMode, @@ -114,7 +114,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { } setIsRunning((prev) => ({ ...prev, [message.id]: false })); }, - [testMode, userEmail], + [testMode, emailAccountId], ); const handleRunAll = async () => { diff --git a/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx index d3f0320fb..55b04e21f 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx @@ -159,7 +159,7 @@ function Content({ }); }, []); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( reportAiMistakeAction.bind(null, email), ); @@ -454,7 +454,7 @@ function GroupMismatchAdd({ onBack: () => void; onClose: () => void; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( addGroupItemAction.bind(null, email), ); @@ -523,7 +523,7 @@ function GroupMismatchRemove({ onBack: () => void; onClose: () => void; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( deleteGroupItemAction.bind(null, email), ); @@ -595,6 +595,7 @@ function CategoryMismatch({ onClose: () => void; }) { const { categories, isLoading } = useCategories(); + const { emailAccountId } = useAccount(); return (
@@ -618,6 +619,7 @@ function CategoryMismatch({ ) : ( & { instructions: string }; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( updateRuleInstructionsAction.bind(null, email), { @@ -794,7 +796,7 @@ function AIFixForm({ fixedInstructions: string; }>(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( reportAiMistakeAction.bind(null, email), { @@ -897,7 +899,7 @@ function SuggestedFix({ }) { const [accepted, setAccepted] = useState(false); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( updateRuleInstructionsAction.bind(null, email), { @@ -973,7 +975,7 @@ function RerunButton({ }) { const [result, setResult] = useState(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction(runRulesAction.bind(null, email), { onSuccess: (result) => { setResult(result?.data); diff --git a/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx index 300aa82ef..61192c769 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx @@ -67,12 +67,13 @@ import { createGroupAction } from "@/utils/actions/group"; import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; import { Badge } from "@/components/Badge"; import { useAccount } from "@/providers/EmailAccountProvider"; + export function RuleForm({ rule, }: { rule: CreateRuleBody & { id?: string }; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { register, @@ -129,7 +130,9 @@ export function RuleForm({ (label) => label.name === action.label, ); if (!hasLabel && action.label?.value && !action.label?.ai) { - await createLabelAction(email, { name: action.label.value }); + await createLabelAction(emailAccountId, { + name: action.label.value, + }); } } } @@ -144,7 +147,10 @@ export function RuleForm({ } if (data.id) { - const res = await updateRuleAction(email, { ...data, id: data.id }); + const res = await updateRuleAction(emailAccountId, { + ...data, + id: data.id, + }); if (res?.serverError) { console.error(res); @@ -164,7 +170,7 @@ export function RuleForm({ router.push("/automation?tab=rules"); } } else { - const res = await createRuleAction(email, data); + const res = await createRuleAction(emailAccountId, data); if (res?.serverError) { console.error(res); @@ -186,7 +192,7 @@ export function RuleForm({ } } }, - [userLabels, router, posthog, email], + [userLabels, router, posthog, emailAccountId], ); const conditions = watch("conditions"); @@ -316,7 +322,7 @@ export function RuleForm({ onClick={async () => { if (!rule.id) return; - const result = await createGroupAction(email, { + const result = await createGroupAction(emailAccountId, { ruleId: rule.id, }); @@ -665,7 +671,7 @@ export function RuleForm({ userLabels={userLabels} isLoading={isLoading} mutate={mutate} - userEmail={email} + emailAccountId={emailAccountId} /> ); })} @@ -755,14 +761,14 @@ function LabelCombobox({ userLabels, isLoading, mutate, - userEmail, + emailAccountId, }: { value: string; onChangeValue: (value: string) => void; userLabels: NonNullable; isLoading: boolean; mutate: () => void; - userEmail: string; + emailAccountId: string; }) { const [search, setSearch] = useState(""); @@ -787,7 +793,7 @@ function LabelCombobox({ onClick={() => { toast.promise( async () => { - const res = await createLabelAction(userEmail, { + const res = await createLabelAction(emailAccountId, { name: search, }); mutate(); @@ -838,7 +844,7 @@ function ActionField({ userLabels, isLoading, mutate, - userEmail, + emailAccountId, }: { field: { name: "label" | "subject" | "content" | "to" | "cc" | "bcc" | "url"; @@ -856,7 +862,7 @@ function ActionField({ userLabels: NonNullable; isLoading: boolean; mutate: () => void; - userEmail: string; + emailAccountId: string; }) { // Get the typed field value safely const getFieldValue = (fieldName: string): string => { @@ -906,7 +912,7 @@ function ActionField({ onChangeValue={(newValue: string) => { setValue(`actions.${i}.${field.name}.value`, newValue); }} - userEmail={userEmail} + emailAccountId={emailAccountId} />
) : isDraftContent && !setManually ? ( diff --git a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx index db94b2fb1..a4919b473 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx @@ -51,7 +51,7 @@ export function Rules() { const hasRules = !!data?.length; - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync: setRuleRunOnThreads } = useAction( setRuleRunOnThreadsAction.bind(null, email), ); diff --git a/apps/web/app/(app)/[emailAccountId]/automation/RulesPrompt.tsx b/apps/web/app/(app)/[emailAccountId]/automation/RulesPrompt.tsx index 9ba0e95df..a0ad60f30 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/RulesPrompt.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/RulesPrompt.tsx @@ -85,7 +85,7 @@ function RulesPromptForm({ mutate: () => void; onOpenPersonaDialog: () => void; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const [isSubmitting, setIsSubmitting] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); diff --git a/apps/web/app/(app)/[emailAccountId]/automation/TestCustomEmailForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/TestCustomEmailForm.tsx index 35ee06510..8d6d538e5 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/TestCustomEmailForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/TestCustomEmailForm.tsx @@ -18,7 +18,7 @@ import { useAccount } from "@/providers/EmailAccountProvider"; export const TestCustomEmailForm = () => { const [testResult, setTestResult] = useState(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { register, diff --git a/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx index a9f507f66..5d8502969 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx @@ -22,7 +22,7 @@ import type { CreateAutomationBody } from "@/utils/actions/ai-rule.validation"; // not in use anymore export default function AutomationSettingsPage() { const router = useRouter(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { register, diff --git a/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx b/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx index 7b5324009..4dca4681b 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx @@ -127,7 +127,7 @@ const AddGroupItemForm = ({ mutate: KeyedMutator; setShowAddItem: Dispatch>; }) => { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { register, @@ -261,7 +261,7 @@ function GroupItemList({ items: GroupItem[]; mutate: KeyedMutator; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); return (
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx index 1edb0f3c6..c1073c486 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeBase.tsx @@ -31,7 +31,7 @@ import { KnowledgeForm } from "@/app/(app)/[emailAccountId]/automation/knowledge import { useAccount } from "@/providers/EmailAccountProvider"; export function KnowledgeBase() { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const [isOpen, setIsOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const { data, isLoading, error, mutate } = diff --git a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx index fc173f19e..6af21bb4a 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/knowledge/KnowledgeForm.tsx @@ -33,7 +33,7 @@ export function KnowledgeForm({ refetch: KeyedMutator; editingItem: Knowledge | null; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { register, diff --git a/apps/web/app/(app)/[emailAccountId]/automation/onboarding/draft-replies/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/draft-replies/page.tsx index c6393382a..155eba214 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/onboarding/draft-replies/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/onboarding/draft-replies/page.tsx @@ -15,7 +15,7 @@ import { useAccount } from "@/providers/EmailAccountProvider"; export default function DraftRepliesPage() { const router = useRouter(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const onSetDraftReplies = useCallback( async (value: string) => { const result = await enableDraftRepliesAction(email, { diff --git a/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list.tsx b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list.tsx index 1c11d4584..232073b5d 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/rule/[ruleId]/examples/example-list.tsx @@ -16,7 +16,7 @@ export function ExampleList({ groupedBySenders: Dictionary; }) { const [removed, setRemoved] = useState([]); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); return (
diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index 8b62ee995..d876916f7 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -143,6 +143,7 @@ export function BulkUnsubscribeRowDesktop({ labels={labels} openPremiumModal={openPremiumModal} userEmail={userEmail} + emailAccountId={emailAccountId} /> diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index ed608c4e7..d08e9beee 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -55,7 +55,7 @@ export function BulkUnsubscribeSection({ refreshInterval: number; isMobile: boolean; }) { - const { email: userEmail } = useAccount(); + const { emailAccountId, userEmail } = useAccount(); const [sortColumn, setSortColumn] = useState< "emails" | "unread" | "unarchived" @@ -105,6 +105,7 @@ export function BulkUnsubscribeSection({ hasUnsubscribeAccess, mutate, userEmail, + emailAccountId, }); const [search, setSearch] = useState(""); diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx index b5de3395e..6da8e4a51 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx @@ -61,6 +61,7 @@ export function ActionCell({ labels, openPremiumModal, userEmail, + emailAccountId, }: { item: T; hasUnsubscribeAccess: boolean; @@ -71,6 +72,7 @@ export function ActionCell({ labels: UserLabel[]; openPremiumModal: () => void; userEmail: string; + emailAccountId: string; }) { const posthog = usePostHog(); @@ -135,6 +137,7 @@ export function ActionCell({ onOpenNewsletter={onOpenNewsletter} item={item} userEmail={userEmail} + emailAccountId={emailAccountId} labels={labels} posthog={posthog} /> @@ -366,12 +369,14 @@ export function MoreDropdown({ onOpenNewsletter, item, userEmail, + emailAccountId, labels, posthog, }: { onOpenNewsletter?: (row: T) => void; item: T; userEmail: string; + emailAccountId: string; labels: UserLabel[]; posthog: PostHog; }) { @@ -426,7 +431,7 @@ export function MoreDropdown({ { - const res = await createFilterAction(userEmail, { + const res = await createFilterAction(emailAccountId, { from: item.name, gmailLabelId: label.id, }); @@ -510,7 +515,7 @@ export function HeaderButton(props: { // { -// const result = await addGroupItemAction(userEmail, { +// const result = await addGroupItemAction(emailAccountId, { // groupId: group.id, // type: GroupItemType.FROM, // value: sender, diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts index 837ae10f2..ff6eaf4aa 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts @@ -19,9 +19,9 @@ async function unsubscribeAndArchive( newsletterEmail: string, mutate: () => Promise, refetchPremium: () => Promise, - userEmail: string, + emailAccountId: string, ) { - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail, status: NewsletterStatus.UNSUBSCRIBED, }); @@ -57,7 +57,7 @@ export function useUnsubscribe({ posthog.capture("Clicked Unsubscribe"); if (item.status === NewsletterStatus.UNSUBSCRIBED) { - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: item.name, status: null, }); @@ -162,7 +162,7 @@ async function autoArchive( from: name, gmailLabelId: labelId, }); - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: name, status: NewsletterStatus.AUTO_ARCHIVED, }); @@ -217,7 +217,7 @@ export function useAutoArchive({ filterId: item.autoArchived.id, }); } - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: item.name, status: null, }); @@ -313,7 +313,7 @@ export function useApproveButton({ setApproveLoading(true); await onDisableAutoArchive(); - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: item.name, status: NewsletterStatus.APPROVED, }); @@ -347,7 +347,7 @@ export function useBulkApprove({ posthog.capture("Clicked Bulk Approve"); for (const item of items) { - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: item.name, status: NewsletterStatus.APPROVED, }); @@ -530,7 +530,8 @@ export function useBulkUnsubscribeShortcuts({ refetchPremium, hasUnsubscribeAccess, mutate, - userEmail, + emailAccountId, + // userEmail, }: { newsletters?: T[]; selectedRow?: T; @@ -539,6 +540,7 @@ export function useBulkUnsubscribeShortcuts({ refetchPremium: () => Promise; hasUnsubscribeAccess: boolean; mutate: () => Promise; + emailAccountId: string; userEmail: string; }) { // perform actions using keyboard shortcuts @@ -575,10 +577,10 @@ export function useBulkUnsubscribeShortcuts({ // auto archive e.preventDefault(); onAutoArchive({ - email: userEmail, + emailAccountId, from: item.name, }); - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: item.name, status: NewsletterStatus.AUTO_ARCHIVED, }); @@ -592,7 +594,7 @@ export function useBulkUnsubscribeShortcuts({ e.preventDefault(); if (!item.lastUnsubscribeLink) return; window.open(cleanUnsubscribeLink(item.lastUnsubscribeLink), "_blank"); - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: item.name, status: NewsletterStatus.UNSUBSCRIBED, }); @@ -604,7 +606,7 @@ export function useBulkUnsubscribeShortcuts({ if (e.key === "a") { // approve e.preventDefault(); - await setNewsletterStatusAction(userEmail, { + await setNewsletterStatusAction(emailAccountId, { newsletterEmail: item.name, status: NewsletterStatus.APPROVED, }); @@ -623,6 +625,7 @@ export function useBulkUnsubscribeShortcuts({ setSelectedRow, onOpenNewsletter, userEmail, + emailAccountId, ]); } diff --git a/apps/web/app/(app)/[emailAccountId]/clean/CleanRun.tsx b/apps/web/app/(app)/[emailAccountId]/clean/CleanRun.tsx index fd3aedfea..4ef99695e 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/CleanRun.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/CleanRun.tsx @@ -10,14 +10,12 @@ export function CleanRun({ threads, total, done, - userEmail, }: { isPreviewBatch: boolean; job: CleanupJob; threads: Awaited>; total: number; done: number; - userEmail: string; }) { return (
@@ -26,7 +24,6 @@ export function CleanRun({ t.status !== "processing")} stats={{ total, done }} - userEmail={userEmail} action={job.action} /> diff --git a/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx index 90d099d38..b0d459779 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx @@ -35,7 +35,7 @@ export function ConfirmationStep({ reuseSettings: boolean; }) { const router = useRouter(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const handleStartCleaning = async () => { const result = await cleanInboxAction(email, { diff --git a/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehose.tsx b/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehose.tsx index 1701fa4a2..42740ac1d 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehose.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehose.tsx @@ -8,11 +8,11 @@ import { EmailItem } from "./EmailFirehoseItem"; import { useEmailStream } from "./useEmailStream"; import type { CleanThread } from "@/utils/redis/clean.types"; import { CleanAction } from "@prisma/client"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function EmailFirehose({ threads, stats, - userEmail, action, }: { threads: CleanThread[]; @@ -20,9 +20,10 @@ export function EmailFirehose({ total: number; done: number; }; - userEmail: string; action: CleanAction; }) { + const { userEmail, emailAccountId } = useAccount(); + const [isPaused, setIsPaused] = useState(false); const [userHasScrolled, setUserHasScrolled] = useState(false); const [tab] = useQueryState("tab", parseAsString.withDefault("archived")); @@ -110,6 +111,7 @@ export function EmailFirehose({ { diff --git a/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehoseItem.tsx b/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehoseItem.tsx index 092059e97..f1e532ba0 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehoseItem.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/EmailFirehoseItem.tsx @@ -25,6 +25,7 @@ type Status = "markedDone" | "markingDone" | "keep" | "labelled" | "processing"; export function EmailItem({ email, userEmail, + emailAccountId, action, undoState, setUndoing, @@ -32,6 +33,7 @@ export function EmailItem({ }: { email: CleanThread; userEmail: string; + emailAccountId: string; action: CleanAction; undoState?: "undoing" | "undone"; setUndoing: (threadId: string) => void; @@ -76,7 +78,7 @@ export function EmailItem({ undoState={undoState} setUndoing={setUndoing} setUndone={setUndone} - userEmail={userEmail} + emailAccountId={emailAccountId} />
@@ -103,7 +105,7 @@ function StatusBadge({ undoState, setUndoing, setUndone, - userEmail, + emailAccountId, }: { status: Status; email: CleanThread; @@ -111,7 +113,7 @@ function StatusBadge({ undoState?: "undoing" | "undone"; setUndoing: (threadId: string) => void; setUndone: (threadId: string) => void; - userEmail: string; + emailAccountId: string; }) { if (status === "processing") { return Processing...; @@ -153,7 +155,7 @@ function StatusBadge({ setUndoing(email.threadId); - const result = await undoCleanInboxAction(userEmail, { + const result = await undoCleanInboxAction(emailAccountId, { threadId: email.threadId, markedDone: !!email.archive, action, @@ -189,7 +191,7 @@ function StatusBadge({ setUndoing(email.threadId); - const result = await changeKeepToDoneAction(userEmail, { + const result = await changeKeepToDoneAction(emailAccountId, { threadId: email.threadId, action, }); diff --git a/apps/web/app/(app)/[emailAccountId]/clean/PreviewBatch.tsx b/apps/web/app/(app)/[emailAccountId]/clean/PreviewBatch.tsx index cdbbd108b..e1b636433 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/PreviewBatch.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/PreviewBatch.tsx @@ -17,7 +17,7 @@ import { PREVIEW_RUN_COUNT } from "@/app/(app)/[emailAccountId]/clean/consts"; import { useAccount } from "@/providers/EmailAccountProvider"; export function PreviewBatch({ job }: { job: CleanupJob }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const [, setIsPreviewBatch] = useQueryState("isPreviewBatch", parseAsBoolean); const [isLoading, setIsLoading] = useState(false); diff --git a/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx index 3780f723f..b7629e7a9 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/run/page.tsx @@ -37,7 +37,7 @@ export default async function CleanRunPage(props: { const [total, done] = await Promise.all([ prisma.cleanupThread.count({ - where: { jobId, emailAccountId: email }, + where: { jobId, emailAccountId }, }), prisma.cleanupThread.count({ where: { jobId, emailAccountId, archived: true }, @@ -51,7 +51,6 @@ export default async function CleanRunPage(props: { threads={threads} total={total} done={done} - userEmail={email} /> ); } diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx index b931f654d..3cb73815e 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx @@ -39,7 +39,7 @@ export function ColdEmailList() { const { selected, isAllSelected, onToggleSelect, onToggleSelectAll } = useToggleSelect(data?.coldEmails || []); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync: markNotColdEmail, isExecuting } = useAction( markNotColdEmailAction.bind(null, email), { diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm.tsx index 12e656778..56bd4ad5d 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailPromptForm.tsx @@ -15,7 +15,7 @@ export function ColdEmailPromptForm(props: { coldEmailPrompt?: string | null; onSuccess: () => void; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { register, diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx index 943bc2cb2..567579a29 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx @@ -27,7 +27,7 @@ export function ColdEmailRejected() { `/api/user/cold-email?page=${page}&status=${ColdEmailStatus.USER_REJECTED_COLD}`, ); - const { email: userEmail } = useAccount(); + const { emailAccountId } = useAccount(); return ( diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx index 5e8a65b6a..912afa3c6 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailSettings.tsx @@ -44,7 +44,7 @@ export function ColdEmailForm({ buttonText?: string; onSuccess?: () => void; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { control, diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/TestRules.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/TestRules.tsx index 889f4ba0f..3db552158 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/TestRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/TestRules.tsx @@ -38,7 +38,7 @@ export function TestRulesContent() { }, ); - const { email: userEmail } = useAccount(); + const { userEmail } = useAccount(); return (
@@ -221,12 +221,12 @@ function useColdEmailTest() { const [response, setResponse] = useState( null, ); - const { email: userEmail } = useAccount(); + const { emailAccountId } = useAccount(); const testEmail = async (data: ColdEmailBlockerBody) => { setTesting(true); try { - const result = await testColdEmailAction(userEmail, data); + const result = await testColdEmailAction(emailAccountId, data); if (result?.serverError) { toastError({ title: "Error checking whether it's a cold email.", diff --git a/apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailForm.tsx b/apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailForm.tsx index 0dbb71307..613c3c8a2 100644 --- a/apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailForm.tsx @@ -52,7 +52,7 @@ export const ComposeEmailForm = ({ onSuccess?: (messageId: string, threadId: string) => void; onDiscard?: () => void; }) => { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const [showFullContent, setShowFullContent] = React.useState(false); const { symbol } = useModifierKey(); const formRef = useRef(null); diff --git a/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx index bc1632504..7eac9bb90 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx @@ -37,7 +37,7 @@ export default function DebugDraftsPage() { parseReplies: true, }); - const { email: userEmail } = useAccount(); + const { emailAccountId } = useAccount(); return (
diff --git a/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx index 8c33eb183..244d2c05d 100644 --- a/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx @@ -23,7 +23,7 @@ import { useAccount } from "@/providers/EmailAccountProvider"; export function EnableReplyTracker({ enabled }: { enabled: boolean }) { const router = useRouter(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); return ( { defaultValues: { about: about ?? "" }, }); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction( saveAboutAction.bind(null, email), diff --git a/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx index e622a00dc..def3cff86 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx @@ -15,7 +15,7 @@ import { useAccount } from "@/providers/EmailAccountProvider"; export function DeleteSection() { const { onCancelLoadBatch } = useStatLoader(); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { executeAsync: executeResetAnalytics } = useAction( resetAnalyticsAction.bind(null, email), ); diff --git a/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx index ac873abc7..977c8d6be 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/LabelsSection.tsx @@ -121,7 +121,7 @@ function LabelsSectionFormInner(props: { .find((l) => l.indexOf(label.toLowerCase()) > -1), ); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); return ( @@ -303,7 +303,7 @@ export function LabelItem(props: { function AddLabelModal() { const [isOpen, setIsOpen] = useState(false); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { mutate } = useSWRConfig(); diff --git a/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx index 70c7e8271..b075316c9 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx @@ -33,7 +33,7 @@ export const SignatureSectionForm = ({ const editorRef = useRef(null); - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction( saveSignatureAction.bind(null, email), { diff --git a/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx index 915b82b66..fb641bc1c 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx @@ -7,7 +7,7 @@ import { useAccount } from "@/providers/EmailAccountProvider"; import { useAction } from "next-safe-action/hooks"; export function RegenerateSecretButton({ hasSecret }: { hasSecret: boolean }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction( regenerateWebhookSecretAction.bind(null, email), { diff --git a/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx b/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx index ec14004e1..6b55c2fa3 100644 --- a/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx @@ -52,7 +52,7 @@ export function SimpleList(props: { nextPageToken?: string | null; type: string; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { toHandleLater, onSetHandled, onSetToHandleLater } = useSimpleProgress(); diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx index 87e1d6076..12fa1b8a7 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx @@ -17,7 +17,7 @@ export function CategorizeWithAiButton({ }: { buttonProps?: ButtonProps; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const [isCategorizing, setIsCategorizing] = useState(false); const { hasAiAccess } = usePremium(); const { PremiumModal, openModal: openPremiumModal } = usePremiumModal(); diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton.tsx index 407a5c92f..569cdee74 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton.tsx @@ -143,7 +143,7 @@ function CreateCategoryForm({ category?: Pick & { id?: string }; closeModal: () => void; }) { - const { email } = useAccount(); + const { emailAccountId } = useAccount(); const { register, diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx index 1d4b33b0f..8a294886d 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx @@ -46,7 +46,7 @@ export function Uncategorized({ [senderAddresses], ); - const { email: userEmail } = useAccount(); + const { emailAccountId } = useAccount(); return ( @@ -67,7 +67,7 @@ export function Uncategorized({ pushToAiCategorizeSenderQueueAtom({ pushIds: senderAddresses, - email: userEmail, + emailAccountId, }); }} > @@ -98,18 +98,14 @@ export function Uncategorized({
{senders?.length ? ( <> - + {hasMore && (
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx index 55b04e21f..364594085 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ReportMistake.tsx @@ -161,7 +161,7 @@ function Content({ const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( - reportAiMistakeAction.bind(null, email), + reportAiMistakeAction.bind(null, emailAccountId), ); const onSelectExpectedRule = useCallback( @@ -456,7 +456,7 @@ function GroupMismatchAdd({ }) { const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( - addGroupItemAction.bind(null, email), + addGroupItemAction.bind(null, emailAccountId), ); return ( @@ -525,7 +525,7 @@ function GroupMismatchRemove({ }) { const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( - deleteGroupItemAction.bind(null, email), + deleteGroupItemAction.bind(null, emailAccountId), ); return ( @@ -729,7 +729,7 @@ function RuleForm({ }) { const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( - updateRuleInstructionsAction.bind(null, email), + updateRuleInstructionsAction.bind(null, emailAccountId), { onSuccess() { toastSuccess({ description: "Rule updated!" }); @@ -798,7 +798,7 @@ function AIFixForm({ const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( - reportAiMistakeAction.bind(null, email), + reportAiMistakeAction.bind(null, emailAccountId), { onSuccess(result) { toastSuccess({ @@ -901,7 +901,7 @@ function SuggestedFix({ const { emailAccountId } = useAccount(); const { executeAsync, isExecuting } = useAction( - updateRuleInstructionsAction.bind(null, email), + updateRuleInstructionsAction.bind(null, emailAccountId), { onSuccess: () => { toastSuccess({ description: "Rule updated!" }); @@ -976,7 +976,7 @@ function RerunButton({ const [result, setResult] = useState(); const { emailAccountId } = useAccount(); - const { execute, isExecuting } = useAction(runRulesAction.bind(null, email), { + const { execute, isExecuting } = useAction(runRulesAction.bind(null, emailAccountId), { onSuccess: (result) => { setResult(result?.data); }, diff --git a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx index a4919b473..005b7f166 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx @@ -53,13 +53,13 @@ export function Rules() { const { emailAccountId } = useAccount(); const { executeAsync: setRuleRunOnThreads } = useAction( - setRuleRunOnThreadsAction.bind(null, email), + setRuleRunOnThreadsAction.bind(null, emailAccountId), ); const { executeAsync: setRuleEnabled } = useAction( - setRuleEnabledAction.bind(null, email), + setRuleEnabledAction.bind(null, emailAccountId), ); const { executeAsync: deleteRule } = useAction( - deleteRuleAction.bind(null, email), + deleteRuleAction.bind(null, emailAccountId), ); return ( diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx index 3cb73815e..35f6bffe6 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx @@ -39,9 +39,9 @@ export function ColdEmailList() { const { selected, isAllSelected, onToggleSelect, onToggleSelectAll } = useToggleSelect(data?.coldEmails || []); - const { emailAccountId } = useAccount(); + const { emailAccountId, userEmail } = useAccount(); const { executeAsync: markNotColdEmail, isExecuting } = useAction( - markNotColdEmailAction.bind(null, email), + markNotColdEmailAction.bind(null, emailAccountId), { onSuccess: () => { toastSuccess({ description: "Marked not cold email!" }); @@ -101,7 +101,7 @@ export function ColdEmailList() { { const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction( - saveAboutAction.bind(null, email), + saveAboutAction.bind(null, emailAccountId), { onSuccess: () => { toastSuccess({ diff --git a/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx index def3cff86..de555552a 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx @@ -17,7 +17,7 @@ export function DeleteSection() { const { emailAccountId } = useAccount(); const { executeAsync: executeResetAnalytics } = useAction( - resetAnalyticsAction.bind(null, email), + resetAnalyticsAction.bind(null, emailAccountId), ); const { executeAsync: executeDeleteAccount } = useAction( deleteAccountAction.bind(null), diff --git a/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx index b075316c9..cfc184585 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx @@ -35,7 +35,7 @@ export const SignatureSectionForm = ({ const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction( - saveSignatureAction.bind(null, email), + saveSignatureAction.bind(null, emailAccountId), { onSuccess: () => { toastSuccess({ description: "Signature saved" }); @@ -48,7 +48,7 @@ export const SignatureSectionForm = ({ }, ); const { executeAsync: executeLoadSignatureFromGmail } = useAction( - loadSignatureFromGmailAction.bind(null, email), + loadSignatureFromGmailAction.bind(null, emailAccountId), ); const handleEditorChange = useCallback( diff --git a/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx index fb641bc1c..bae925139 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx @@ -9,7 +9,7 @@ import { useAction } from "next-safe-action/hooks"; export function RegenerateSecretButton({ hasSecret }: { hasSecret: boolean }) { const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction( - regenerateWebhookSecretAction.bind(null, email), + regenerateWebhookSecretAction.bind(null, emailAccountId), { onSuccess: () => { toastSuccess({ From 421a28534f6e5b7ee0586f100ad1d21e95ec4e6d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 03:07:44 +0300 Subject: [PATCH 087/176] fixes --- .../bulk-unsubscribe/BulkActions.tsx | 14 +- .../BulkUnsubscribeMobile.tsx | 4 + .../bulk-unsubscribe/common.tsx | 27 +-- .../bulk-unsubscribe/hooks.ts | 211 +++++++++++------- .../bulk-unsubscribe/types.ts | 3 +- .../onboarding/OnboardingBulkUnsubscriber.tsx | 6 + apps/web/components/GroupedTable.tsx | 5 +- apps/web/store/archive-sender-queue.ts | 26 ++- 8 files changed, 192 insertions(+), 104 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions.tsx index e2a0834f6..f251cbd49 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions.tsx @@ -15,6 +15,7 @@ import { import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; import { Button } from "@/components/ui/button"; import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; +import { useAccount } from "@/providers/EmailAccountProvider"; export function BulkActions({ selected, @@ -26,28 +27,35 @@ export function BulkActions({ const posthog = usePostHog(); const { hasUnsubscribeAccess, mutate: refetchPremium } = usePremium(); const { PremiumModal, openModal } = usePremiumModal(); - + const { emailAccountId } = useAccount(); const { bulkUnsubscribeLoading, onBulkUnsubscribe } = useBulkUnsubscribe({ hasUnsubscribeAccess, mutate, posthog, refetchPremium, + emailAccountId, }); const { bulkApproveLoading, onBulkApprove } = useBulkApprove({ mutate, posthog, + emailAccountId, }); const { bulkAutoArchiveLoading, onBulkAutoArchive } = useBulkAutoArchive({ hasUnsubscribeAccess, mutate, refetchPremium, + emailAccountId, }); - const { onBulkArchive } = useBulkArchive({ mutate, posthog }); + const { onBulkArchive } = useBulkArchive({ + mutate, + posthog, + emailAccountId, + }); - const { onBulkDelete } = useBulkDelete({ mutate, posthog }); + const { onBulkDelete } = useBulkDelete({ mutate, posthog, emailAccountId }); const getSelectedValues = () => Array.from(selected.entries()) diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx index c1bdc8e07..bd595404d 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx @@ -44,6 +44,7 @@ export function BulkUnsubscribeRowMobile({ onOpenNewsletter, readPercentage, archivedPercentage, + emailAccountId, }: RowProps) { const name = extractNameFromEmail(item.name); const email = extractEmailAddress(item.name); @@ -54,6 +55,7 @@ export function BulkUnsubscribeRowMobile({ item, mutate, posthog, + emailAccountId, }); const { unsubscribeLoading, onUnsubscribe, unsubscribeLink } = useUnsubscribe( { @@ -62,11 +64,13 @@ export function BulkUnsubscribeRowMobile({ mutate, refetchPremium, posthog, + emailAccountId, }, ); const { archiveAllLoading, onArchiveAll } = useArchiveAll({ item, posthog, + emailAccountId, }); const hasUnsubscribeLink = unsubscribeLink !== "#"; diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx index 6da8e4a51..a5cd9118d 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/common.tsx @@ -88,7 +88,7 @@ export function ActionCell({ mutate={mutate} posthog={posthog} refetchPremium={refetchPremium} - userEmail={userEmail} + emailAccountId={emailAccountId} /> ({ posthog={posthog} refetchPremium={refetchPremium} labels={labels} - userEmail={userEmail} + emailAccountId={emailAccountId} /> ({ hasUnsubscribeAccess={hasUnsubscribeAccess} mutate={mutate} posthog={posthog} - userEmail={userEmail} + emailAccountId={emailAccountId} /> ({ mutate, posthog, refetchPremium, - userEmail, + emailAccountId, }: { item: T; hasUnsubscribeAccess: boolean; mutate: () => Promise; refetchPremium: () => Promise; posthog: PostHog; - userEmail: string; + emailAccountId: string; }) { const { unsubscribeLoading, onUnsubscribe, unsubscribeLink } = useUnsubscribe( { @@ -167,7 +167,7 @@ function UnsubscribeButton({ mutate, posthog, refetchPremium, - userEmail, + emailAccountId, }, ); @@ -208,7 +208,7 @@ function AutoArchiveButton({ posthog, refetchPremium, labels, - userEmail, + emailAccountId, }: { item: T; hasUnsubscribeAccess: boolean; @@ -216,7 +216,7 @@ function AutoArchiveButton({ posthog: PostHog; refetchPremium: () => Promise; labels: UserLabel[]; - userEmail: string; + emailAccountId: string; }) { const { autoArchiveLoading, @@ -229,7 +229,7 @@ function AutoArchiveButton({ mutate, posthog, refetchPremium, - userEmail, + emailAccountId, }); return ( @@ -330,19 +330,19 @@ function ApproveButton({ hasUnsubscribeAccess, mutate, posthog, - userEmail, + emailAccountId, }: { item: T; hasUnsubscribeAccess: boolean; mutate: () => Promise; posthog: PostHog; - userEmail: string; + emailAccountId: string; }) { const { approveLoading, onApprove } = useApproveButton({ item, mutate, posthog, - userEmail, + emailAccountId, }); return ( @@ -383,11 +383,12 @@ export function MoreDropdown({ const { archiveAllLoading, onArchiveAll } = useArchiveAll({ item, posthog, + emailAccountId, }); const { deleteAllLoading, onDeleteAll } = useDeleteAllFromSender({ item, posthog, - userEmail, + emailAccountId, }); return ( diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts index ff6eaf4aa..1b7ae2d13 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts @@ -15,12 +15,17 @@ import type { Row } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import type { GetThreadsResponse } from "@/app/api/google/threads/basic/route"; import { isDefined } from "@/utils/types"; -async function unsubscribeAndArchive( - newsletterEmail: string, - mutate: () => Promise, - refetchPremium: () => Promise, - emailAccountId: string, -) { +async function unsubscribeAndArchive({ + newsletterEmail, + mutate, + refetchPremium, + emailAccountId, +}: { + newsletterEmail: string; + mutate: () => Promise; + refetchPremium: () => Promise; + emailAccountId: string; +}) { await setNewsletterStatusAction(emailAccountId, { newsletterEmail, status: NewsletterStatus.UNSUBSCRIBED, @@ -28,19 +33,22 @@ async function unsubscribeAndArchive( await mutate(); await decrementUnsubscribeCreditAction(); await refetchPremium(); - await addToArchiveSenderQueue(newsletterEmail); + await addToArchiveSenderQueue({ + sender: newsletterEmail, + emailAccountId, + }); } export function useUnsubscribe({ item, - userEmail, + emailAccountId, hasUnsubscribeAccess, mutate, posthog, refetchPremium, }: { item: T; - userEmail: string; + emailAccountId: string; hasUnsubscribeAccess: boolean; mutate: () => Promise; posthog: PostHog; @@ -63,12 +71,12 @@ export function useUnsubscribe({ }); await mutate(); } else { - await unsubscribeAndArchive( - item.name, + await unsubscribeAndArchive({ + newsletterEmail: item.name, mutate, refetchPremium, - userEmail, - ); + emailAccountId, + }); } } catch (error) { captureException(error); @@ -83,7 +91,7 @@ export function useUnsubscribe({ mutate, refetchPremium, posthog, - userEmail, + emailAccountId, ]); return { @@ -101,13 +109,13 @@ export function useBulkUnsubscribe({ mutate, posthog, refetchPremium, - userEmail, + emailAccountId, }: { hasUnsubscribeAccess: boolean; mutate: () => Promise; posthog: PostHog; refetchPremium: () => Promise; - userEmail: string; + emailAccountId: string; }) { const [bulkUnsubscribeLoading, setBulkUnsubscribeLoading] = React.useState(false); @@ -123,12 +131,12 @@ export function useBulkUnsubscribe({ for (const item of items) { try { - await unsubscribeAndArchive( - item.name, + await unsubscribeAndArchive({ + newsletterEmail: item.name, mutate, refetchPremium, - userEmail, - ); + emailAccountId, + }); } catch (error) { captureException(error); console.error(error); @@ -141,7 +149,7 @@ export function useBulkUnsubscribe({ setBulkUnsubscribeLoading(false); }, - [hasUnsubscribeAccess, mutate, posthog, refetchPremium, userEmail], + [hasUnsubscribeAccess, mutate, posthog, refetchPremium, emailAccountId], ); return { @@ -150,15 +158,21 @@ export function useBulkUnsubscribe({ }; } -async function autoArchive( - name: string, - labelId: string | undefined, - mutate: () => Promise, - refetchPremium: () => Promise, - userEmail: string, -) { +async function autoArchive({ + name, + labelId, + mutate, + refetchPremium, + emailAccountId, +}: { + name: string; + labelId: string | undefined; + mutate: () => Promise; + refetchPremium: () => Promise; + emailAccountId: string; +}) { await onAutoArchive({ - email: userEmail, + emailAccountId, from: name, gmailLabelId: labelId, }); @@ -169,7 +183,11 @@ async function autoArchive( await mutate(); await decrementUnsubscribeCreditAction(); await refetchPremium(); - await addToArchiveSenderQueue(name, labelId); + await addToArchiveSenderQueue({ + sender: name, + labelId, + emailAccountId, + }); } export function useAutoArchive({ @@ -178,14 +196,14 @@ export function useAutoArchive({ mutate, posthog, refetchPremium, - userEmail, + emailAccountId, }: { item: T; hasUnsubscribeAccess: boolean; mutate: () => Promise; posthog: PostHog; refetchPremium: () => Promise; - userEmail: string; + emailAccountId: string; }) { const [autoArchiveLoading, setAutoArchiveLoading] = React.useState(false); @@ -194,7 +212,13 @@ export function useAutoArchive({ setAutoArchiveLoading(true); - await autoArchive(item.name, undefined, mutate, refetchPremium, userEmail); + await autoArchive({ + name: item.name, + labelId: undefined, + mutate, + refetchPremium, + emailAccountId, + }); posthog.capture("Clicked Auto Archive"); @@ -205,7 +229,7 @@ export function useAutoArchive({ refetchPremium, hasUnsubscribeAccess, posthog, - userEmail, + emailAccountId, ]); const onDisableAutoArchive = useCallback(async () => { @@ -213,7 +237,7 @@ export function useAutoArchive({ if (item.autoArchived?.id) { await onDeleteFilter({ - email: userEmail, + emailAccountId, filterId: item.autoArchived.id, }); } @@ -224,7 +248,7 @@ export function useAutoArchive({ await mutate(); setAutoArchiveLoading(false); - }, [item.name, item.autoArchived?.id, mutate, userEmail]); + }, [item.name, item.autoArchived?.id, mutate, emailAccountId]); const onAutoArchiveAndLabel = useCallback( async (labelId: string) => { @@ -232,11 +256,17 @@ export function useAutoArchive({ setAutoArchiveLoading(true); - await autoArchive(item.name, labelId, mutate, refetchPremium, userEmail); + await autoArchive({ + name: item.name, + labelId, + mutate, + refetchPremium, + emailAccountId, + }); setAutoArchiveLoading(false); }, - [item.name, mutate, refetchPremium, hasUnsubscribeAccess, userEmail], + [item.name, mutate, refetchPremium, hasUnsubscribeAccess, emailAccountId], ); return { @@ -251,12 +281,12 @@ export function useBulkAutoArchive({ hasUnsubscribeAccess, mutate, refetchPremium, - userEmail, + emailAccountId, }: { hasUnsubscribeAccess: boolean; mutate: () => Promise; refetchPremium: () => Promise; - userEmail: string; + emailAccountId: string; }) { const [bulkAutoArchiveLoading, setBulkAutoArchiveLoading] = React.useState(false); @@ -268,18 +298,18 @@ export function useBulkAutoArchive({ setBulkAutoArchiveLoading(true); for (const item of items) { - await autoArchive( - item.name, - undefined, + await autoArchive({ + name: item.name, + labelId: undefined, mutate, refetchPremium, - userEmail, - ); + emailAccountId, + }); } setBulkAutoArchiveLoading(false); }, - [hasUnsubscribeAccess, mutate, refetchPremium, userEmail], + [hasUnsubscribeAccess, mutate, refetchPremium, emailAccountId], ); return { @@ -292,12 +322,12 @@ export function useApproveButton({ item, mutate, posthog, - userEmail, + emailAccountId, }: { item: T; mutate: () => Promise; posthog: PostHog; - userEmail: string; + emailAccountId: string; }) { const [approveLoading, setApproveLoading] = React.useState(false); const { onDisableAutoArchive } = useAutoArchive({ @@ -306,7 +336,7 @@ export function useApproveButton({ mutate, posthog, refetchPremium: () => Promise.resolve(), - userEmail, + emailAccountId, }); const onApprove = async () => { @@ -333,11 +363,11 @@ export function useApproveButton({ export function useBulkApprove({ mutate, posthog, - userEmail, + emailAccountId, }: { mutate: () => Promise; posthog: PostHog; - userEmail: string; + emailAccountId: string; }) { const [bulkApproveLoading, setBulkApproveLoading] = React.useState(false); @@ -363,19 +393,27 @@ export function useBulkApprove({ }; } -async function archiveAll(name: string, onFinish: () => void) { +async function archiveAll({ + name, + onFinish, + emailAccountId, +}: { + name: string; + onFinish: () => void; + emailAccountId: string; +}) { toast.promise( async () => { const threadsArchived = await new Promise((resolve, reject) => { - addToArchiveSenderQueue( - name, - undefined, - (totalThreads) => { + addToArchiveSenderQueue({ + sender: name, + emailAccountId, + onSuccess: (totalThreads) => { onFinish(); resolve(totalThreads); }, - reject, - ); + onError: reject, + }); }); return threadsArchived; @@ -394,9 +432,11 @@ async function archiveAll(name: string, onFinish: () => void) { export function useArchiveAll({ item, posthog, + emailAccountId, }: { item: T; posthog: PostHog; + emailAccountId: string; }) { const [archiveAllLoading, setArchiveAllLoading] = React.useState(false); @@ -405,7 +445,11 @@ export function useArchiveAll({ posthog.capture("Clicked Archive All"); - await archiveAll(item.name, () => setArchiveAllLoading(false)); + await archiveAll({ + name: item.name, + onFinish: () => setArchiveAllLoading(false), + emailAccountId, + }); setArchiveAllLoading(false); }; @@ -419,26 +463,36 @@ export function useArchiveAll({ export function useBulkArchive({ mutate, posthog, + emailAccountId, }: { mutate: () => Promise; posthog: PostHog; + emailAccountId: string; }) { const onBulkArchive = async (items: T[]) => { posthog.capture("Clicked Bulk Archive"); for (const item of items) { - await archiveAll(item.name, mutate); + await archiveAll({ + name: item.name, + onFinish: mutate, + emailAccountId, + }); } }; return { onBulkArchive }; } -async function deleteAllFromSender( - name: string, - onFinish: () => void, - userEmail: string, -) { +async function deleteAllFromSender({ + name, + onFinish, + emailAccountId, +}: { + name: string; + onFinish: () => void; + emailAccountId: string; +}) { toast.promise( async () => { // 1. search gmail for messages from sender @@ -455,7 +509,7 @@ async function deleteAllFromSender( resolve(); }, onError: reject, - email: userEmail, + emailAccountId, }); }); } @@ -476,11 +530,11 @@ async function deleteAllFromSender( export function useDeleteAllFromSender({ item, posthog, - userEmail, + emailAccountId, }: { item: T; posthog: PostHog; - userEmail: string; + emailAccountId: string; }) { const [deleteAllLoading, setDeleteAllLoading] = React.useState(false); @@ -489,11 +543,11 @@ export function useDeleteAllFromSender({ posthog.capture("Clicked Delete All"); - await deleteAllFromSender( - item.name, - () => setDeleteAllLoading(false), - userEmail, - ); + await deleteAllFromSender({ + name: item.name, + onFinish: () => setDeleteAllLoading(false), + emailAccountId, + }); }; return { @@ -505,17 +559,21 @@ export function useDeleteAllFromSender({ export function useBulkDelete({ mutate, posthog, - userEmail, + emailAccountId, }: { mutate: () => Promise; posthog: PostHog; - userEmail: string; + emailAccountId: string; }) { const onBulkDelete = async (items: T[]) => { posthog.capture("Clicked Bulk Delete"); for (const item of items) { - await deleteAllFromSender(item.name, mutate, userEmail); + await deleteAllFromSender({ + name: item.name, + onFinish: mutate, + emailAccountId, + }); } }; @@ -624,7 +682,6 @@ export function useBulkUnsubscribeShortcuts({ refetchPremium, setSelectedRow, onOpenNewsletter, - userEmail, emailAccountId, ]); } diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/types.ts b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/types.ts index 8fdf7ab9d..6891b7bfc 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/types.ts +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/types.ts @@ -12,6 +12,8 @@ export type Row = { type Newsletter = NewsletterStatsResponse["newsletters"][number]; export interface RowProps { + emailAccountId: string; + userEmail: string; item: Newsletter; readPercentage: number; archivedEmails: number; @@ -19,7 +21,6 @@ export interface RowProps { onOpenNewsletter: (row: Newsletter) => void; labels: UserLabel[]; - userEmail: string; mutate: () => Promise; selected: boolean; onSelectRow: () => void; diff --git a/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx b/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx index 2f12a0176..3187bb0ef 100644 --- a/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx +++ b/apps/web/app/(app)/onboarding/OnboardingBulkUnsubscriber.tsx @@ -25,6 +25,7 @@ import { ONE_MONTH_MS } from "@/utils/date"; import { useUnsubscribe } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks"; import { NewsletterStatus } from "@prisma/client"; import { EmailCell } from "@/components/EmailCell"; +import { useAccount } from "@/providers/EmailAccountProvider"; const useNewsletterStats = () => { const now = useMemo(() => Date.now(), []); @@ -48,6 +49,7 @@ const useNewsletterStats = () => { export function OnboardingBulkUnsubscriber() { const { data, isLoading, error, mutate } = useNewsletterStats(); + const { emailAccountId } = useAccount(); const posthog = usePostHog(); @@ -99,6 +101,7 @@ export function OnboardingBulkUnsubscriber() { row={row} posthog={posthog} mutate={mutate} + emailAccountId={emailAccountId} /> ))} @@ -127,10 +130,12 @@ function UnsubscribeRow({ row, posthog, mutate, + emailAccountId, }: { row: NewsletterStatsResponse["newsletters"][number]; posthog: PostHog; mutate: () => Promise; + emailAccountId: string; }) { const { unsubscribeLoading, onUnsubscribe, unsubscribeLink } = useUnsubscribe( { @@ -139,6 +144,7 @@ function UnsubscribeRow({ mutate, refetchPremium: () => Promise.resolve(), posthog, + emailAccountId, }, ); diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 29305882e..70456c53b 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -208,7 +208,10 @@ export function GroupedTable({ const onArchiveAll = async () => { for (const sender of senders) { - await addToArchiveSenderQueue(sender.address); + await addToArchiveSenderQueue({ + sender: sender.address, + emailAccountId, + }); } }; diff --git a/apps/web/store/archive-sender-queue.ts b/apps/web/store/archive-sender-queue.ts index f264319b3..a343ec45e 100644 --- a/apps/web/store/archive-sender-queue.ts +++ b/apps/web/store/archive-sender-queue.ts @@ -15,12 +15,19 @@ interface QueueItem { const archiveSenderQueueAtom = atom>(new Map()); -export async function addToArchiveSenderQueue( - sender: string, - labelId?: string, - onSuccess?: (totalThreads: number) => void, - onError?: (sender: string) => void, -) { +export async function addToArchiveSenderQueue({ + sender, + labelId, + onSuccess, + onError, + emailAccountId, +}: { + sender: string; + labelId?: string; + onSuccess?: (totalThreads: number) => void; + onError?: (sender: string) => void; + emailAccountId: string; +}) { // Add sender with pending status jotaiStore.set(archiveSenderQueueAtom, (prev) => { // Skip if sender is already in queue @@ -52,10 +59,10 @@ export async function addToArchiveSenderQueue( } // Add threads to archive queue - await archiveEmails( + await archiveEmails({ threadIds, labelId, - (threadId) => { + onSuccess: (threadId) => { const senderItem = jotaiStore.get(archiveSenderQueueAtom).get(sender); if (!senderItem) return; @@ -83,7 +90,8 @@ export async function addToArchiveSenderQueue( } }, onError, - ); + emailAccountId, + }); } catch (error) { // Remove sender from queue on error jotaiStore.set(archiveSenderQueueAtom, (prev) => { From 8dc44031ccca19d5b4f0b5bce6fc99d4080e14a0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 10:42:44 +0300 Subject: [PATCH 088/176] fix --- .../bulk-unsubscribe/BulkUnsubscribeDesktop.tsx | 6 +++++- .../bulk-unsubscribe/BulkUnsubscribeSection.tsx | 1 + .../cold-email-blocker/ColdEmailRejected.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index d876916f7..a5c272683 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -10,7 +10,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { ActionCell, HeaderButton } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/common"; +import { + ActionCell, + HeaderButton, +} from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/common"; import type { RowProps } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/types"; import { Checkbox } from "@/components/Checkbox"; @@ -81,6 +84,7 @@ export function BulkUnsubscribeRowDesktop({ labels, openPremiumModal, userEmail, + emailAccountId, onToggleSelect, checked, readPercentage, diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index d08e9beee..8bc6175d6 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -145,6 +145,7 @@ export function BulkUnsubscribeSection({ key={item.name} item={item} userEmail={userEmail} + emailAccountId={emailAccountId} onOpenNewsletter={onOpenNewsletter} labels={userLabels} mutate={mutate} diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx index 567579a29..09b3b9aa0 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailRejected.tsx @@ -27,7 +27,7 @@ export function ColdEmailRejected() { `/api/user/cold-email?page=${page}&status=${ColdEmailStatus.USER_REJECTED_COLD}`, ); - const { emailAccountId } = useAccount(); + const { emailAccountId, userEmail } = useAccount(); return ( From e0c31b25349ad68379502a3a400a4e06cd111cf1 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 11:17:44 +0300 Subject: [PATCH 089/176] fix tests --- .../utils/cold-email/is-cold-email.test.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/web/utils/cold-email/is-cold-email.test.ts b/apps/web/utils/cold-email/is-cold-email.test.ts index 6841c013d..773b72039 100644 --- a/apps/web/utils/cold-email/is-cold-email.test.ts +++ b/apps/web/utils/cold-email/is-cold-email.test.ts @@ -5,6 +5,7 @@ import { ColdEmailSetting, ColdEmailStatus } from "@prisma/client"; import { GmailLabel } from "@/utils/gmail/label"; import * as labelUtils from "@/utils/gmail/label"; import { blockColdEmail } from "./is-cold-email"; +import { getEmailAccount } from "@/__tests__/helpers"; // Mock dependencies vi.mock("server-only", () => ({})); @@ -33,14 +34,9 @@ describe("blockColdEmail", () => { id: "123", threadId: "thread123", }; - const mockUser = { - userId: "user123", - about: "", - email: "user@example.com", + const mockEmailAccount = { + ...getEmailAccount(), coldEmailBlocker: ColdEmailSetting.LABEL, - aiProvider: null, - aiModel: null, - aiApiKey: null, }; const mockAiReason = "This is a cold email"; @@ -52,14 +48,14 @@ describe("blockColdEmail", () => { await blockColdEmail({ gmail: mockGmail, email: mockEmail, - user: mockUser, + emailAccount: mockEmailAccount, aiReason: mockAiReason, }); expect(prisma.coldEmail.upsert).toHaveBeenCalledWith({ where: { - userId_fromEmail: { - userId: mockUser.userId, + emailAccountId_fromEmail: { + emailAccountId: mockEmailAccount.id, fromEmail: mockEmail.from, }, }, @@ -67,7 +63,7 @@ describe("blockColdEmail", () => { create: { status: ColdEmailStatus.AI_LABELED_COLD, fromEmail: mockEmail.from, - userId: mockUser.userId, + emailAccountId: mockEmailAccount.id, reason: mockAiReason, messageId: mockEmail.id, threadId: mockEmail.threadId, @@ -83,7 +79,7 @@ describe("blockColdEmail", () => { await blockColdEmail({ gmail: mockGmail, email: mockEmail, - user: mockUser, + emailAccount: mockEmailAccount, aiReason: mockAiReason, }); @@ -101,7 +97,7 @@ describe("blockColdEmail", () => { it("should archive email when coldEmailBlocker is ARCHIVE_AND_LABEL", async () => { const userWithArchive = { - ...mockUser, + ...mockEmailAccount, coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_LABEL, }; vi.mocked(labelUtils.getOrCreateInboxZeroLabel).mockResolvedValue({ @@ -111,7 +107,7 @@ describe("blockColdEmail", () => { await blockColdEmail({ gmail: mockGmail, email: mockEmail, - user: userWithArchive, + emailAccount: userWithArchive, aiReason: mockAiReason, }); @@ -125,7 +121,7 @@ describe("blockColdEmail", () => { it("should archive and mark as read when coldEmailBlocker is ARCHIVE_AND_READ_AND_LABEL", async () => { const userWithArchiveAndRead = { - ...mockUser, + ...mockEmailAccount, coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL, }; vi.mocked(labelUtils.getOrCreateInboxZeroLabel).mockResolvedValue({ @@ -135,7 +131,7 @@ describe("blockColdEmail", () => { await blockColdEmail({ gmail: mockGmail, email: mockEmail, - user: userWithArchiveAndRead, + emailAccount: userWithArchiveAndRead, aiReason: mockAiReason, }); @@ -148,13 +144,13 @@ describe("blockColdEmail", () => { }); it("should throw error when user email is missing", async () => { - const userWithoutEmail = { ...mockUser, email: null as any }; + const userWithoutEmail = { ...mockEmailAccount, email: null as any }; await expect( blockColdEmail({ gmail: mockGmail, email: mockEmail, - user: userWithoutEmail, + emailAccount: userWithoutEmail, aiReason: mockAiReason, }), ).rejects.toThrow("User email is required"); @@ -168,7 +164,7 @@ describe("blockColdEmail", () => { await blockColdEmail({ gmail: mockGmail, email: mockEmail, - user: mockUser, + emailAccount: mockEmailAccount, aiReason: mockAiReason, }); @@ -182,14 +178,14 @@ describe("blockColdEmail", () => { it("should not modify labels when coldEmailBlocker is DISABLED", async () => { const userWithBlockerOff = { - ...mockUser, + ...mockEmailAccount, coldEmailBlocker: ColdEmailSetting.DISABLED, }; await blockColdEmail({ gmail: mockGmail, email: mockEmail, - user: userWithBlockerOff, + emailAccount: userWithBlockerOff, aiReason: mockAiReason, }); From f5fb0b0f1559fca8edca10908ee2c29b33a44484 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 11:38:17 +0300 Subject: [PATCH 090/176] fix tests --- .../webhook/process-history-item.test.ts | 114 +++++++++--------- .../google/webhook/process-history-item.ts | 2 +- .../app/api/google/webhook/process-history.ts | 5 +- apps/web/app/api/google/webhook/types.ts | 2 - apps/web/utils/api-auth.test.ts | 2 +- 5 files changed, 59 insertions(+), 66 deletions(-) diff --git a/apps/web/app/api/google/webhook/process-history-item.test.ts b/apps/web/app/api/google/webhook/process-history-item.test.ts index 089819ca3..14e79aeba 100644 --- a/apps/web/app/api/google/webhook/process-history-item.test.ts +++ b/apps/web/app/api/google/webhook/process-history-item.test.ts @@ -14,6 +14,7 @@ import { GmailLabel } from "@/utils/gmail/label"; import { categorizeSender } from "@/utils/categorize/senders/categorize"; import { runRules } from "@/utils/ai/choose-rule/run-rules"; import { processAssistantEmail } from "@/utils/assistant/process-assistant-email"; +import { getEmailAccount } from "@/__tests__/helpers"; vi.mock("server-only", () => ({})); vi.mock("next/server", () => ({ @@ -31,7 +32,7 @@ vi.mock("@/utils/gmail/message", () => ({ payload: { headers: [ { name: "From", value: "sender@example.com" }, - { name: "To", value: "user@example.com" }, + { name: "To", value: "user@test.com" }, { name: "Subject", value: "Test Email" }, { name: "Date", value: "2024-01-01T00:00:00Z" }, ], @@ -54,7 +55,7 @@ vi.mock("@/utils/gmail/thread", () => ({ internalDate: "1704067200000", // 2024-01-01T00:00:00Z headers: { from: "sender@example.com", - to: "user@example.com", + to: "user@test.com", subject: "Test Email", date: "2024-01-01T00:00:00Z", }, @@ -95,54 +96,35 @@ describe("processHistoryItem", () => { message: { id: messageId, threadId }, }); - interface TestUser { - id: string; - userId: string; - email: string | null; - about: string | null; - coldEmailBlocker: ColdEmailSetting | null; - coldEmailPrompt: string | null; - autoCategorizeSenders: boolean; - aiProvider: string; - aiModel: string; - aiApiKey: string | null; - } - - const defaultUser: TestUser = { - id: "user-123", - userId: "user-123", - email: "user@example.com", - about: null, - coldEmailBlocker: ColdEmailSetting.DISABLED, - coldEmailPrompt: null, - autoCategorizeSenders: false, - aiProvider: "openai", - aiModel: "gpt-4", - aiApiKey: null, + const defaultOptions = { + gmail: {} as any, + email: "user@test.com", + accessToken: "fake-token", + hasColdEmailAccess: false, + hasAutomationRules: false, + hasAiAutomationAccess: false, + rules: [], + history: [] as gmail_v1.Schema$History[], }; - const createOptions = (overrides: { [key: string]: any } = {}) => { - const user = overrides.user - ? { ...defaultUser, ...overrides.user } - : defaultUser; + function getDefaultEmailAccount() { return { - gmail: {} as any, - email: "user@example.com", - user, - accessToken: "fake-token", - hasColdEmailAccess: false, - hasAutomationRules: false, - hasAiAutomationAccess: false, - rules: [], - history: [] as gmail_v1.Schema$History[], - ...overrides, + ...getEmailAccount(), + coldEmailPrompt: null, + coldEmailBlocker: ColdEmailSetting.DISABLED, + autoCategorizeSenders: false, }; - }; + } it("should skip if message is already being processed", async () => { vi.mocked(markMessageAsProcessing).mockResolvedValueOnce(false); - await processHistoryItem(createHistoryItem(), createOptions()); + const options = { + ...defaultOptions, + emailAccount: getDefaultEmailAccount(), + }; + + await processHistoryItem(createHistoryItem(), options); expect(getMessage).not.toHaveBeenCalled(); }); @@ -150,7 +132,11 @@ describe("processHistoryItem", () => { it("should skip if message is an assistant email", async () => { vi.mocked(isAssistantEmail).mockReturnValueOnce(true); - await processHistoryItem(createHistoryItem(), createOptions()); + const options = { + ...defaultOptions, + emailAccount: getDefaultEmailAccount(), + }; + await processHistoryItem(createHistoryItem(), options); expect(blockUnsubscribedEmails).not.toHaveBeenCalled(); expect(runColdEmailBlocker).not.toHaveBeenCalled(); @@ -158,11 +144,11 @@ describe("processHistoryItem", () => { message: expect.objectContaining({ headers: expect.objectContaining({ from: "sender@example.com", - to: "user@example.com", + to: "user@test.com", }), }), - userEmail: "user@example.com", - userId: "user-123", + userEmail: "user@test.com", + emailAccountId: "email-account-id", gmail: expect.any(Object), }); }); @@ -174,7 +160,7 @@ describe("processHistoryItem", () => { labelIds: [GmailLabel.SENT], payload: { headers: [ - { name: "From", value: "user@example.com" }, + { name: "From", value: "user@test.com" }, { name: "To", value: "recipient@example.com" }, { name: "Subject", value: "Test Email" }, { name: "Date", value: "2024-01-01T00:00:00Z" }, @@ -182,7 +168,11 @@ describe("processHistoryItem", () => { }, }); - await processHistoryItem(createHistoryItem(), createOptions()); + const options = { + ...defaultOptions, + emailAccount: getDefaultEmailAccount(), + }; + await processHistoryItem(createHistoryItem(), options); expect(blockUnsubscribedEmails).not.toHaveBeenCalled(); expect(runColdEmailBlocker).not.toHaveBeenCalled(); @@ -191,19 +181,24 @@ describe("processHistoryItem", () => { it("should skip if email is unsubscribed", async () => { vi.mocked(blockUnsubscribedEmails).mockResolvedValueOnce(true); - await processHistoryItem(createHistoryItem(), createOptions()); + const options = { + ...defaultOptions, + emailAccount: getDefaultEmailAccount(), + }; + await processHistoryItem(createHistoryItem(), options); expect(runColdEmailBlocker).not.toHaveBeenCalled(); }); it("should run cold email blocker when enabled", async () => { - const options = createOptions({ - user: { - ...defaultUser, + const options = { + ...defaultOptions, + emailAccount: { + ...getDefaultEmailAccount(), coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_LABEL, }, hasColdEmailAccess: true, - }); + }; await processHistoryItem(createHistoryItem(), options); @@ -217,7 +212,7 @@ describe("processHistoryItem", () => { date: expect.any(Date), }), gmail: options.gmail, - user: options.user, + emailAccount: options.emailAccount, }); }); @@ -228,16 +223,17 @@ describe("processHistoryItem", () => { aiReason: "This appears to be a cold email", }); - const options = createOptions({ - user: { - ...defaultUser, + const options = { + ...defaultOptions, + emailAccount: { + ...getDefaultEmailAccount(), coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_LABEL, + autoCategorizeSenders: true, }, hasColdEmailAccess: true, hasAutomationRules: true, hasAiAutomationAccess: true, - autoCategorizeSenders: true, - }); + }; await processHistoryItem(createHistoryItem(), options); diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index be038caa9..d95737460 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -31,7 +31,6 @@ export async function processHistoryItem( }: gmail_v1.Schema$HistoryMessageAdded | gmail_v1.Schema$HistoryLabelAdded, { gmail, - userEmail, emailAccount, accessToken, hasColdEmailAccess, @@ -43,6 +42,7 @@ export async function processHistoryItem( const messageId = message?.id; const threadId = message?.threadId; const emailAccountId = emailAccount.id; + const userEmail = emailAccount.email; if (!messageId || !threadId) return; diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index ed1086016..33550103b 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -166,8 +166,6 @@ export async function processHistoryForUser( await processHistory({ history: history.history, - userEmail: emailAccount.email, - emailAccountId: emailAccount.id, gmail, accessToken: emailAccount.account?.access_token, hasAutomationRules, @@ -212,7 +210,8 @@ export async function processHistoryForUser( } async function processHistory(options: ProcessHistoryOptions) { - const { history, userEmail, emailAccountId } = options; + const { history, emailAccount } = options; + const { email: userEmail, id: emailAccountId } = emailAccount; if (!history?.length) return; diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts index f8a5554c2..506491b30 100644 --- a/apps/web/app/api/google/webhook/types.ts +++ b/apps/web/app/api/google/webhook/types.ts @@ -5,8 +5,6 @@ import type { EmailAccount } from "@prisma/client"; export type ProcessHistoryOptions = { history: gmail_v1.Schema$History[]; - userEmail: string; - emailAccountId: string; gmail: gmail_v1.Gmail; accessToken: string; rules: RuleWithActionsAndCategories[]; diff --git a/apps/web/utils/api-auth.test.ts b/apps/web/utils/api-auth.test.ts index 816275d90..75c3f2bc9 100644 --- a/apps/web/utils/api-auth.test.ts +++ b/apps/web/utils/api-auth.test.ts @@ -215,7 +215,7 @@ describe("api-auth", () => { ); await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( - SafeError, + Error, ); await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( "Error refreshing Gmail access token", From 7bfcfbaccc2d0d62997383aa1fefa88ca2cd2415 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 12:04:55 +0300 Subject: [PATCH 091/176] fixes --- .../[emailAccountId]/PermissionsCheck.tsx | 8 +- .../[emailAccountId]/debug/drafts/page.tsx | 7 +- .../reply-zero/EnableReplyTracker.tsx | 4 +- .../reply-zero/onboarding/page.tsx | 2 +- .../settings/ModelSection.tsx | 8 +- .../(app)/[emailAccountId]/settings/page.tsx | 2 +- .../[emailAccountId]/simple/SimpleList.tsx | 27 +- .../CategorizeWithAiButton.tsx | 3 +- .../setup/SetUpCategories.tsx | 11 +- .../smart-categories/setup/page.tsx | 12 +- .../[emailAccountId]/stats/EmailAnalytics.tsx | 9 +- .../stats/NewsletterModal.tsx | 15 +- .../app/(app)/[emailAccountId]/usage/page.tsx | 2 +- .../api/ai/analyze-sender-pattern/route.ts | 16 +- .../user/categorize/senders/progress/route.ts | 20 +- .../user/group/[groupId]/messages/route.ts | 8 +- apps/web/app/api/user/labels/route.ts | 6 +- .../api/user/planned/get-executed-rules.ts | 2 +- apps/web/app/api/user/planned/route.ts | 10 +- apps/web/app/api/v1/reply-tracker/route.ts | 2 +- apps/web/components/AccountSwitcher.tsx | 66 +++-- apps/web/components/CommandK.tsx | 12 +- apps/web/components/PremiumAlert.tsx | 2 +- apps/web/components/email-list/EmailList.tsx | 34 ++- .../components/email-list/EmailListItem.tsx | 4 +- apps/web/providers/SWRProvider.tsx | 8 +- .../scripts/migrateRedisPlansToPostgres.ts | 272 ------------------ apps/web/store/QueueInitializer.tsx | 4 +- apps/web/utils/reply-tracker/enable.ts | 11 +- 29 files changed, 195 insertions(+), 392 deletions(-) delete mode 100644 apps/web/scripts/migrateRedisPlansToPostgres.ts diff --git a/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx index 208a8e846..3d0f14c56 100644 --- a/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx +++ b/apps/web/app/(app)/[emailAccountId]/PermissionsCheck.tsx @@ -12,16 +12,16 @@ export function PermissionsCheck() { const { emailAccountId } = useAccount(); useEffect(() => { - if (permissionsChecked[email]) return; - permissionsChecked[email] = true; + if (permissionsChecked[emailAccountId]) return; + permissionsChecked[emailAccountId] = true; - checkPermissionsAction(email).then((result) => { + checkPermissionsAction(emailAccountId).then((result) => { if (result?.data?.hasAllPermissions === false) router.replace("/permissions/error"); if (result?.data?.hasRefreshToken === false) router.replace("/permissions/consent"); }); - }, [router, email]); + }, [router, emailAccountId]); return null; } diff --git a/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx index 7eac9bb90..5b068738e 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/drafts/page.tsx @@ -79,7 +79,7 @@ export default function DebugDraftsPage() { ) : executedAction.draftId ? ( diff --git a/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx index 244d2c05d..93d2ce4c4 100644 --- a/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker.tsx @@ -67,7 +67,7 @@ export function EnableReplyTracker({ enabled }: { enabled: boolean }) { return; } - const result = await enableReplyTrackerAction(email); + const result = await enableReplyTrackerAction(emailAccountId); if (result?.serverError) { toastError({ @@ -83,7 +83,7 @@ export function EnableReplyTracker({ enabled }: { enabled: boolean }) { toast.promise( async () => { - processPreviousSentEmailsAction(email); + processPreviousSentEmailsAction(emailAccountId); router.push("/reply-zero?enabled=true"); }, diff --git a/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx index 141399afa..17240aa28 100644 --- a/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx @@ -9,7 +9,7 @@ export default async function OnboardingReplyTracker(props: { const trackerRule = await prisma.rule.findFirst({ where: { - emailAccount: { accountId: params.account }, + emailAccount: { id: params.emailAccountId }, actions: { some: { type: ActionType.TRACK_THREAD } }, }, select: { id: true }, diff --git a/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx index eecdc08b8..8aed21f8c 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx @@ -26,7 +26,7 @@ export function ModelSection() { const { data, isLoading, error, mutate } = useUser(); const { data: dataModels, isLoading: isLoadingModels } = useSWR( - data?.aiApiKey && data.aiProvider === Provider.OPEN_AI + data?.user.aiApiKey && data?.user.aiProvider === Provider.OPEN_AI ? "/api/ai/models" : null, ); @@ -41,9 +41,9 @@ export function ModelSection() { {data && ( diff --git a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx index dd595b842..2c8ca1d56 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx @@ -17,7 +17,7 @@ export default async function SettingsPage(props: { const emailAccountId = params.emailAccountId; const user = await prisma.emailAccount.findUnique({ - where: { accountId }, + where: { id: emailAccountId }, select: { about: true, signature: true, diff --git a/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx b/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx index 6b55c2fa3..60728eda0 100644 --- a/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/simple/SimpleList.tsx @@ -52,7 +52,7 @@ export function SimpleList(props: { nextPageToken?: string | null; type: string; }) { - const { emailAccountId } = useAccount(); + const { emailAccountId, userEmail } = useAccount(); const { toHandleLater, onSetHandled, onSetToHandleLater } = useSimpleProgress(); @@ -85,7 +85,8 @@ export function SimpleList(props: { handleUnsubscribe(message.id)} @@ -105,7 +106,11 @@ export function SimpleList(props: { startTransition(() => { onSetHandled(toArchive); - archiveEmails(toArchive, undefined, () => {}); + archiveEmails({ + threadIds: toArchive, + emailAccountId, + onSuccess: () => {}, + }); if (props.nextPageToken) { router.push( @@ -139,12 +144,14 @@ export function SimpleList(props: { function SimpleListRow({ message, userEmail, + emailAccountId, toHandleLater, onSetToHandleLater, handleUnsubscribe, }: { message: ParsedMessage; userEmail: string; + emailAccountId: string; toHandleLater: Record; onSetToHandleLater: (ids: string[]) => void; handleUnsubscribe: (id: string) => void; @@ -213,14 +220,20 @@ function SimpleListRow({ {/* TODO only show one of these two buttons */} { - markImportantMessageAction(message.id, true); + markImportantMessageAction(emailAccountId, { + messageId: message.id, + important: true, + }); }} > Mark Important { - markImportantMessageAction(message.id, false); + markImportantMessageAction(emailAccountId, { + messageId: message.id, + important: false, + }); }} > Mark Unimportant @@ -229,7 +242,9 @@ function SimpleListRow({ {/* Unsubscribe */} { - markSpamThreadAction(message.threadId); + markSpamThreadAction(emailAccountId, { + threadId: message.threadId, + }); }} > Mark Spam diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx index 12fa1b8a7..caf0a2996 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx @@ -40,7 +40,8 @@ export function CategorizeWithAiButton({ async () => { setIsCategorizing(true); setIsBulkCategorizing(true); - const result = await bulkCategorizeSendersAction(email); + const result = + await bulkCategorizeSendersAction(emailAccountId); if (result?.serverError) { setIsCategorizing(false); diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories.tsx index bcb6e4d26..20469f8ed 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories.tsx @@ -25,6 +25,7 @@ import { CreateCategoryDialog, } from "@/app/(app)/[emailAccountId]/smart-categories/CreateCategoryButton"; import type { Category } from "@prisma/client"; +import { useAccount } from "@/providers/EmailAccountProvider"; type CardCategory = Pick & { id?: string; @@ -49,6 +50,8 @@ export function SetUpCategories({ const [selectedCategoryName, setSelectedCategoryName] = useQueryState("category-name"); + const { emailAccountId } = useAccount(); + const combinedCategories = uniqBy( [ ...defaultCategories.map((c) => { @@ -134,7 +137,9 @@ export function SetUpCategories({ } onRemove={async () => { if (category.id) { - await deleteCategoryAction(category.id); + await deleteCategoryAction(emailAccountId, { + categoryId: category.id, + }); } else { setCategories( new Map(categories.entries()).set(category.name, false), @@ -170,7 +175,9 @@ export function SetUpCategories({ }), ); - await upsertDefaultCategoriesAction(upsertCategories); + await upsertDefaultCategoriesAction(emailAccountId, { + categories: upsertCategories, + }); setIsCreating(false); router.push("/smart-categories"); }} diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx index b18dd122e..1405b8039 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/setup/page.tsx @@ -1,15 +1,15 @@ import { SetUpCategories } from "@/app/(app)/[emailAccountId]/smart-categories/setup/SetUpCategories"; import { SmartCategoriesOnboarding } from "@/app/(app)/[emailAccountId]/smart-categories/setup/SmartCategoriesOnboarding"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { ClientOnly } from "@/components/ClientOnly"; import { getUserCategories } from "@/utils/category.server"; -export default async function SetupCategoriesPage() { - const session = await auth(); - const email = session?.user.email; - if (!email) throw new Error("Not authenticated"); +export default async function SetupCategoriesPage(props: { + params: Promise<{ emailAccountId: string }>; +}) { + const params = await props.params; + const emailAccountId = params.emailAccountId; - const categories = await getUserCategories({ email }); + const categories = await getUserCategories({ emailAccountId }); return ( <> diff --git a/apps/web/app/(app)/[emailAccountId]/stats/EmailAnalytics.tsx b/apps/web/app/(app)/[emailAccountId]/stats/EmailAnalytics.tsx index be7f58497..2ee7e982d 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/EmailAnalytics.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/EmailAnalytics.tsx @@ -11,11 +11,12 @@ import { BarList } from "@/components/charts/BarList"; import { getDateRangeParams } from "@/app/(app)/[emailAccountId]/stats/params"; import { getGmailSearchUrl } from "@/utils/url"; import { useAccount } from "@/providers/EmailAccountProvider"; + export function EmailAnalytics(props: { dateRange?: DateRange | undefined; refreshInterval: number; }) { - const { emailAccountId } = useAccount(); + const { userEmail } = useAccount(); const params = getDateRangeParams(props.dateRange); @@ -55,7 +56,7 @@ export function EmailAnalytics(props: { ?.slice(0, expanded ? undefined : 5) .map((d) => ({ ...d, - href: getGmailSearchUrl(d.name, email), + href: getGmailSearchUrl(d.name, userEmail), target: "_blank", }))} extra={extra} @@ -76,7 +77,7 @@ export function EmailAnalytics(props: { ?.slice(0, expanded ? undefined : 5) .map((d) => ({ ...d, - href: getGmailSearchUrl(d.name, email), + href: getGmailSearchUrl(d.name, userEmail), target: "_blank", }))} extra={extra} @@ -98,7 +99,7 @@ export function EmailAnalytics(props: { ?.slice(0, expanded ? undefined : 5) .map((d) => ({ ...d, - href: getGmailSearchUrl(d.name, email), + href: getGmailSearchUrl(d.name, userEmail), target: "_blank", })) || [] } diff --git a/apps/web/app/(app)/[emailAccountId]/stats/NewsletterModal.tsx b/apps/web/app/(app)/[emailAccountId]/stats/NewsletterModal.tsx index c378d7b1e..4b496a528 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/NewsletterModal.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/NewsletterModal.tsx @@ -38,7 +38,7 @@ export function NewsletterModal(props: { }) { const { newsletter, refreshInterval, onClose } = props; - const { emailAccountId } = useAccount(); + const { emailAccountId, userEmail } = useAccount(); const { userLabels } = useLabels(); @@ -68,7 +68,10 @@ export function NewsletterModal(props: { size="sm" variant="outline" onClick={() => { - onAutoArchive({ email, from: newsletter.name }); + onAutoArchive({ + emailAccountId, + from: newsletter.name, + }); }} > Auto archive @@ -76,7 +79,10 @@ export function NewsletterModal(props: { {newsletter.autoArchived && ( )} diff --git a/apps/web/app/(app)/[emailAccountId]/automation/History.tsx b/apps/web/app/(app)/[emailAccountId]/automation/History.tsx index 7cd9d7aa6..b44ff2a06 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/History.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/History.tsx @@ -72,7 +72,7 @@ function HistoryTable({ data: PlanHistoryResponse["executedRules"]; totalPages: number; }) { - const { userEmail } = useAccount(); + const { userEmail, emailAccountId } = useAccount(); return (
@@ -101,6 +101,7 @@ function HistoryTable({ void; @@ -226,6 +228,8 @@ function FinalStepReady({ }: StepProps & { result: ResultProps; }) { + const { emailAccountId } = useAccount(); + function getDescription() { let message = ""; @@ -267,7 +271,9 @@ function FinalStepReady({ Back
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx index 005b7f166..aef4fca2a 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx @@ -45,7 +45,7 @@ import { ActionType } from "@prisma/client"; import { ThreadsExplanation } from "@/app/(app)/[emailAccountId]/automation/RuleForm"; import { useAction } from "next-safe-action/hooks"; import { useAccount } from "@/providers/EmailAccountProvider"; - +import { prefixPath } from "@/utils/path"; export function Rules() { const { data, isLoading, error, mutate } = useRules(); @@ -220,13 +220,13 @@ export function Rules() { {hasRules && (
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx index 9406c479e..91e4fdd08 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/create/page.tsx @@ -18,6 +18,7 @@ import { toastError } from "@/components/Toast"; import { examples } from "@/app/(app)/[emailAccountId]/automation/create/examples"; import { useAccount } from "@/providers/EmailAccountProvider"; import type { CreateAutomationBody } from "@/utils/actions/ai-rule.validation"; +import { prefixPath } from "@/utils/path"; // not in use anymore export default function AutomationSettingsPage() { @@ -133,7 +134,11 @@ export default function AutomationSettingsPage() {
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index 585aa72d1..7b941aabe 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -17,6 +17,7 @@ import { TabsToolbar } from "@/components/TabsToolbar"; import { GmailProvider } from "@/providers/GmailProvider"; import { ASSISTANT_ONBOARDING_COOKIE } from "@/utils/cookies"; import { Button } from "@/components/ui/button"; +import { prefixPath } from "@/utils/path"; export const maxDuration = 300; // Applies to the actions @@ -64,7 +65,11 @@ export default async function AutomationPage({
Error loading groups} // // -// +// // // New Group // diff --git a/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx b/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx index 54c6f2fe7..0e718a34f 100644 --- a/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx +++ b/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx @@ -28,7 +28,6 @@ import { PremiumTier } from "@prisma/client"; import { usePricingFrequencyDefault, usePricingVariant, - useSkipUpgrade, } from "@/hooks/useFeatureFlags"; export function Pricing(props: { @@ -46,8 +45,6 @@ export function Pricing(props: { const affiliateCode = useAffiliateCode(); const premiumTier = getUserTier(premium); - const skipVariant = useSkipUpgrade(); - const header = props.header || (
@@ -287,17 +284,6 @@ export function Pricing(props: { ); })} - - {props.showSkipUpgrade && skipVariant === "skip-button" && ( -
- -
- )}
); diff --git a/apps/web/app/(app)/[emailAccountId]/setup/page.tsx b/apps/web/app/(app)/[emailAccountId]/setup/page.tsx index 169f12518..324c38a41 100644 --- a/apps/web/app/(app)/[emailAccountId]/setup/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/setup/page.tsx @@ -8,12 +8,12 @@ import { BotIcon, type LucideIcon, } from "lucide-react"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { PageHeading, SectionDescription } from "@/components/Typography"; import { LoadStats } from "@/providers/StatLoaderProvider"; import { Card } from "@/components/ui/card"; import { REPLY_ZERO_ONBOARDING_COOKIE } from "@/utils/cookies"; +import { prefixPath } from "@/utils/path"; export default async function SetupPage(props: { params: Promise<{ emailAccountId: string }>; @@ -261,8 +261,6 @@ function Checklist({ isBulkUnsubscribeConfigured: boolean; isAiAssistantConfigured: boolean; }) { - const prefixPath = (path: `/${string}`) => `/${emailAccountId}${path}`; - return (
@@ -283,7 +281,7 @@ function Checklist({
} iconBg="bg-green-100 dark:bg-green-900/50" iconColor="text-green-500 dark:text-green-400" @@ -295,7 +293,7 @@ function Checklist({ /> } iconBg="bg-purple-100 dark:bg-purple-900/50" iconColor="text-purple-500 dark:text-purple-400" @@ -307,7 +305,7 @@ function Checklist({ /> } iconBg="bg-blue-100 dark:bg-blue-900/50" iconColor="text-blue-500 dark:text-blue-400" diff --git a/apps/web/app/api/google/watch/route.ts b/apps/web/app/api/google/watch/route.ts index 220e4575e..10802d19b 100644 --- a/apps/web/app/api/google/watch/route.ts +++ b/apps/web/app/api/google/watch/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import { getGmailClient } from "@/utils/gmail/client"; import { watchEmails } from "./controller"; import { withEmailAccount } from "@/utils/middleware"; import { createScopedLogger } from "@/utils/logger"; diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index e03e505f9..f9910a342 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -52,6 +52,7 @@ import { useCleanerEnabled } from "@/hooks/useFeatureFlags"; import { ClientOnly } from "@/components/ClientOnly"; import { AccountSwitcher } from "@/components/AccountSwitcher"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; type NavItem = { name: string; @@ -72,17 +73,17 @@ export const useNavigation = () => { () => [ { name: "Personal Assistant", - href: `/${emailAccountId}/automation`, + href: prefixPath(emailAccountId, "/automation"), icon: SparklesIcon, }, { name: "Reply Zero", - href: `/${emailAccountId}/reply-zero`, + href: prefixPath(emailAccountId, "/reply-zero"), icon: MessageCircleReplyIcon, }, { name: "Cold Email Blocker", - href: `/${emailAccountId}/cold-email-blocker`, + href: prefixPath(emailAccountId, "/cold-email-blocker"), icon: ShieldCheckIcon, }, ], @@ -94,17 +95,17 @@ export const useNavigation = () => { () => [ { name: "Bulk Unsubscribe", - href: `/${emailAccountId}/bulk-unsubscribe`, + href: prefixPath(emailAccountId, "/bulk-unsubscribe"), icon: MailsIcon, }, { name: "Deep Clean", - href: `/${emailAccountId}/clean`, + href: prefixPath(emailAccountId, "/clean"), icon: BrushIcon, }, { name: "Analytics", - href: `/${emailAccountId}/stats`, + href: prefixPath(emailAccountId, "/stats"), icon: BarChartBigIcon, }, ], diff --git a/apps/web/components/email-list/EmailList.tsx b/apps/web/components/email-list/EmailList.tsx index d0e0bc605..74588e6be 100644 --- a/apps/web/components/email-list/EmailList.tsx +++ b/apps/web/components/email-list/EmailList.tsx @@ -32,6 +32,7 @@ import { markReadThreads, } from "@/store/archive-queue"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; export function List({ emails, @@ -48,6 +49,7 @@ export function List({ isLoadingMore?: boolean; handleLoadMore?: () => void; }) { + const { emailAccountId } = useAccount(); const [selectedTab] = useQueryState("tab", { defaultValue: "all" }); const categories = useMemo(() => { @@ -124,7 +126,7 @@ export function List({ <> Set rules on the{" "} Automation page diff --git a/apps/web/hooks/useFeatureFlags.ts b/apps/web/hooks/useFeatureFlags.ts index d70de475e..326cc8c0a 100644 --- a/apps/web/hooks/useFeatureFlags.ts +++ b/apps/web/hooks/useFeatureFlags.ts @@ -28,15 +28,6 @@ export function usePricingVariant() { ); } -export type SkipUpgradeVariant = "control" | "skip-button"; - -export function useSkipUpgrade() { - return ( - (useFeatureFlagVariantKey("skip-upgrade") as SkipUpgradeVariant) || - "control" - ); -} - export type PricingFrequencyDefault = "control" | "monthly"; export function usePricingFrequencyDefault() { diff --git a/apps/web/utils/path.ts b/apps/web/utils/path.ts new file mode 100644 index 000000000..e297be289 --- /dev/null +++ b/apps/web/utils/path.ts @@ -0,0 +1,2 @@ +export const prefixPath = (emailAccountId: string, path: `/${string}`) => + `/${emailAccountId}${path}`; From cdc255c9f0a29904bc66a9b61c95b5128ca334b4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 13:18:17 +0300 Subject: [PATCH 097/176] more path fixes. move premium to global --- .../automation/BulkRunRules.tsx | 4 +-- .../[emailAccountId]/automation/RuleForm.tsx | 28 +++++++++++-------- .../bulk-unsubscribe/BulkActions.tsx | 2 +- .../BulkUnsubscribeSection.tsx | 2 +- .../clean/ConfirmationStep.tsx | 11 ++++++-- .../[emailAccountId]/clean/history/page.tsx | 9 ++++-- .../cold-email-blocker/ColdEmailList.tsx | 4 ++- .../app/(app)/[emailAccountId]/debug/page.tsx | 17 +++++++---- .../settings/MultiAccountSection.tsx | 4 +-- .../CategorizeWithAiButton.tsx | 2 +- .../smart-categories/Uncategorized.tsx | 2 +- .../premium/PremiumModal.tsx | 2 +- .../premium/Pricing.tsx | 2 +- .../{[emailAccountId] => }/premium/config.ts | 0 .../{[emailAccountId] => }/premium/page.tsx | 2 +- apps/web/app/(app)/settings/page.tsx | 5 ++++ apps/web/app/(landing)/ai-automation/page.tsx | 2 +- .../app/(landing)/block-cold-emails/page.tsx | 2 +- .../bulk-email-unsubscriber/page.tsx | 2 +- .../app/(landing)/email-analytics/page.tsx | 2 +- apps/web/app/(landing)/page.tsx | 2 +- apps/web/app/(landing)/reply-zero-ai/page.tsx | 2 +- .../app/(landing)/welcome-upgrade/page.tsx | 2 +- .../app/api/lemon-squeezy/webhook/route.ts | 2 +- apps/web/components/PremiumAlert.tsx | 4 +-- apps/web/utils/actions/premium.ts | 2 +- 26 files changed, 74 insertions(+), 44 deletions(-) rename apps/web/app/(app)/{[emailAccountId] => }/premium/PremiumModal.tsx (89%) rename apps/web/app/(app)/{[emailAccountId] => }/premium/Pricing.tsx (99%) rename apps/web/app/(app)/{[emailAccountId] => }/premium/config.ts (100%) rename apps/web/app/(app)/{[emailAccountId] => }/premium/page.tsx (72%) create mode 100644 apps/web/app/(app)/settings/page.tsx diff --git a/apps/web/app/(app)/[emailAccountId]/automation/BulkRunRules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/BulkRunRules.tsx index 201291a3a..adc1faff0 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/BulkRunRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/BulkRunRules.tsx @@ -23,7 +23,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { useAccount } from "@/providers/EmailAccountProvider"; - +import { prefixPath } from "@/utils/path"; export function BulkRunRules() { const { emailAccountId } = useAccount(); @@ -125,7 +125,7 @@ export function BulkRunRules() { You can also process specific emails by visiting the{" "} diff --git a/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx index 61192c769..9af2d449a 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/RuleForm.tsx @@ -67,12 +67,9 @@ import { createGroupAction } from "@/utils/actions/group"; import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; import { Badge } from "@/components/Badge"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; -export function RuleForm({ - rule, -}: { - rule: CreateRuleBody & { id?: string }; -}) { +export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { emailAccountId } = useAccount(); const { @@ -543,7 +540,10 @@ export function RuleForm({ className="ml-2" > Create category @@ -558,7 +558,13 @@ export function RuleForm({
diff --git a/apps/web/app/(app)/[emailAccountId]/debug/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/page.tsx index 80b6a1c5f..ae6ee19a1 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/page.tsx @@ -1,18 +1,25 @@ +import Link from "next/link"; import { PageHeading } from "@/components/Typography"; import { Button } from "@/components/ui/button"; -import Link from "next/link"; +import { prefixPath } from "@/utils/path"; + +export default async function DebugPage(props: { + params: Promise<{ emailAccountId: string }>; +}) { + const { emailAccountId } = await props.params; -export default function DebugPage() { return (
Debug -
+
diff --git a/apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx index 48281fadf..8e34458ed 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/MultiAccountSection.tsx @@ -24,11 +24,11 @@ import { import type { MultiAccountEmailsResponse } from "@/app/api/user/settings/multi-account/route"; import { AlertBasic, AlertWithButton } from "@/components/Alert"; import { usePremium } from "@/components/PremiumAlert"; -import { pricingAdditonalEmail } from "@/app/(app)/[emailAccountId]/premium/config"; +import { pricingAdditonalEmail } from "@/app/(app)/premium/config"; import { PremiumTier } from "@prisma/client"; import { env } from "@/env"; import { getUserTier, isAdminForPremium } from "@/utils/premium"; -import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; import { useAction } from "next-safe-action/hooks"; import { toastError, toastSuccess } from "@/components/Toast"; diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx index caf0a2996..8ed11fce1 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/CategorizeWithAiButton.tsx @@ -6,7 +6,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { bulkCategorizeSendersAction } from "@/utils/actions/categorize"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; -import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; import type { ButtonProps } from "@/components/ui/button"; import { useCategorizeProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; import { Tooltip } from "@/components/Tooltip"; diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx index 8a294886d..02f5c62b5 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/Uncategorized.tsx @@ -18,7 +18,7 @@ import { import { SectionDescription } from "@/components/Typography"; import { ButtonLoader } from "@/components/Loading"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; -import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; import { Toggle } from "@/components/Toggle"; import { setAutoCategorizeAction } from "@/utils/actions/categorize"; import { TooltipExplanation } from "@/components/TooltipExplanation"; diff --git a/apps/web/app/(app)/[emailAccountId]/premium/PremiumModal.tsx b/apps/web/app/(app)/premium/PremiumModal.tsx similarity index 89% rename from apps/web/app/(app)/[emailAccountId]/premium/PremiumModal.tsx rename to apps/web/app/(app)/premium/PremiumModal.tsx index 7b651b54e..25a16af99 100644 --- a/apps/web/app/(app)/[emailAccountId]/premium/PremiumModal.tsx +++ b/apps/web/app/(app)/premium/PremiumModal.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; export function usePremiumModal() { const [isOpen, setIsOpen] = useState(false); diff --git a/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx b/apps/web/app/(app)/premium/Pricing.tsx similarity index 99% rename from apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx rename to apps/web/app/(app)/premium/Pricing.tsx index 0e718a34f..3995e5e27 100644 --- a/apps/web/app/(app)/[emailAccountId]/premium/Pricing.tsx +++ b/apps/web/app/(app)/premium/Pricing.tsx @@ -20,7 +20,7 @@ import { enterpriseTier, frequencies, pricingAdditonalEmail, -} from "@/app/(app)/[emailAccountId]/premium/config"; +} from "@/app/(app)/premium/config"; import { AlertWithButton } from "@/components/Alert"; import { switchPremiumPlanAction } from "@/utils/actions/premium"; import { TooltipExplanation } from "@/components/TooltipExplanation"; diff --git a/apps/web/app/(app)/[emailAccountId]/premium/config.ts b/apps/web/app/(app)/premium/config.ts similarity index 100% rename from apps/web/app/(app)/[emailAccountId]/premium/config.ts rename to apps/web/app/(app)/premium/config.ts diff --git a/apps/web/app/(app)/[emailAccountId]/premium/page.tsx b/apps/web/app/(app)/premium/page.tsx similarity index 72% rename from apps/web/app/(app)/[emailAccountId]/premium/page.tsx rename to apps/web/app/(app)/premium/page.tsx index 31a466708..a54528d5a 100644 --- a/apps/web/app/(app)/[emailAccountId]/premium/page.tsx +++ b/apps/web/app/(app)/premium/page.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; export default function Premium() { return ( diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx new file mode 100644 index 000000000..f372e379b --- /dev/null +++ b/apps/web/app/(app)/settings/page.tsx @@ -0,0 +1,5 @@ +import { redirectToEmailAccountPath } from "@/utils/account"; + +export default async function SettingsPage() { + await redirectToEmailAccountPath("/settings"); +} diff --git a/apps/web/app/(landing)/ai-automation/page.tsx b/apps/web/app/(landing)/ai-automation/page.tsx index d4d5f3e26..185874818 100644 --- a/apps/web/app/(landing)/ai-automation/page.tsx +++ b/apps/web/app/(landing)/ai-automation/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { FeaturesAiAssistant } from "@/app/(landing)/home/Features"; diff --git a/apps/web/app/(landing)/block-cold-emails/page.tsx b/apps/web/app/(landing)/block-cold-emails/page.tsx index 842b74016..2ddd74554 100644 --- a/apps/web/app/(landing)/block-cold-emails/page.tsx +++ b/apps/web/app/(landing)/block-cold-emails/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesColdEmailBlocker } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx b/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx index d93aec8a7..6e161daa1 100644 --- a/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx +++ b/apps/web/app/(landing)/bulk-email-unsubscriber/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesUnsubscribe } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/email-analytics/page.tsx b/apps/web/app/(landing)/email-analytics/page.tsx index d9872bfd5..e8a50cd30 100644 --- a/apps/web/app/(landing)/email-analytics/page.tsx +++ b/apps/web/app/(landing)/email-analytics/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { FeaturesStats } from "@/app/(landing)/home/Features"; diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx index 075f3677b..4f2ec7bb2 100644 --- a/apps/web/app/(landing)/page.tsx +++ b/apps/web/app/(landing)/page.tsx @@ -4,7 +4,7 @@ import { HeroHome } from "@/app/(landing)/home/Hero"; import { FeaturesHome } from "@/app/(landing)/home/Features"; import { Privacy } from "@/app/(landing)/home/Privacy"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/reply-zero-ai/page.tsx b/apps/web/app/(landing)/reply-zero-ai/page.tsx index ae33515cf..5a1eface1 100644 --- a/apps/web/app/(landing)/reply-zero-ai/page.tsx +++ b/apps/web/app/(landing)/reply-zero-ai/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; import { Hero } from "@/app/(landing)/home/Hero"; import { FeaturesReplyZero } from "@/app/(landing)/home/Features"; import { Testimonials } from "@/app/(landing)/home/Testimonials"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; import { FAQs } from "@/app/(landing)/home/FAQs"; import { CTA } from "@/app/(landing)/home/CTA"; import { BasicLayout } from "@/components/layouts/BasicLayout"; diff --git a/apps/web/app/(landing)/welcome-upgrade/page.tsx b/apps/web/app/(landing)/welcome-upgrade/page.tsx index ed7472252..2e0f2793c 100644 --- a/apps/web/app/(landing)/welcome-upgrade/page.tsx +++ b/apps/web/app/(landing)/welcome-upgrade/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import { CheckCircleIcon } from "lucide-react"; -import { Pricing } from "@/app/(app)/[emailAccountId]/premium/Pricing"; +import { Pricing } from "@/app/(app)/premium/Pricing"; import { Footer } from "@/app/(landing)/home/Footer"; import { Loading } from "@/components/Loading"; import { WelcomeUpgradeNav } from "@/app/(landing)/welcome-upgrade/WelcomeUpgradeNav"; diff --git a/apps/web/app/api/lemon-squeezy/webhook/route.ts b/apps/web/app/api/lemon-squeezy/webhook/route.ts index 187a3cc67..4454640f8 100644 --- a/apps/web/app/api/lemon-squeezy/webhook/route.ts +++ b/apps/web/app/api/lemon-squeezy/webhook/route.ts @@ -18,7 +18,7 @@ import { upgradedToPremium, } from "@inboxzero/loops"; import { SafeError } from "@/utils/error"; -import { getSubscriptionTier } from "@/app/(app)/[emailAccountId]/premium/config"; +import { getSubscriptionTier } from "@/app/(app)/premium/config"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("Lemon Squeezy Webhook"); diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index 0f9071597..91e91f23d 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -11,10 +11,10 @@ import { isPremium, } from "@/utils/premium"; import { Tooltip } from "@/components/Tooltip"; -import { usePremiumModal } from "@/app/(app)/[emailAccountId]/premium/PremiumModal"; +import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; import { PremiumTier } from "@prisma/client"; import { useUser } from "@/hooks/useUser"; -import { businessTierName } from "@/app/(app)/[emailAccountId]/premium/config"; +import { businessTierName } from "@/app/(app)/premium/config"; export function usePremium() { const swrResponse = useUser(); diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index c1255f6cb..3658c2f89 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -15,7 +15,7 @@ import { } from "@/app/api/lemon-squeezy/api"; import { PremiumTier } from "@prisma/client"; import { ONE_MONTH_MS, ONE_YEAR_MS } from "@/utils/date"; -import { getVariantId } from "@/app/(app)/[emailAccountId]/premium/config"; +import { getVariantId } from "@/app/(app)/premium/config"; import { actionClientUser, adminActionClient, From 6c6baa937a80e96c11e7eb009b1bb6d388926375 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 27 Apr 2025 13:19:17 +0300 Subject: [PATCH 098/176] path --- .../web/app/(app)/[emailAccountId]/smart-categories/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx index 5ff58a15e..53249d725 100644 --- a/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/smart-categories/page.tsx @@ -25,6 +25,7 @@ import { PremiumAlertWithData } from "@/components/PremiumAlert"; import { Button } from "@/components/ui/button"; import { CategorizeSendersProgress } from "@/app/(app)/[emailAccountId]/smart-categories/CategorizeProgress"; import { getCategorizationProgress } from "@/utils/redis/categorization-progress"; +import { prefixPath } from "@/utils/path"; export const dynamic = "force-dynamic"; export const maxDuration = 300; @@ -88,7 +89,9 @@ export default async function CategoriesPage({ }} /> - +
diff --git a/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx index bc2f506cd..db69765c0 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/ModelSection.tsx @@ -58,7 +58,7 @@ export function ModelSection() { } function getDefaultModel(aiProvider: string | null) { - const provider = aiProvider || Provider.ANTHROPIC; + const provider = aiProvider || DEFAULT_PROVIDER; const models = modelOptions[provider]; return models?.[0]?.value; } @@ -94,7 +94,7 @@ function ModelSectionForm(props: { // if model not part of provider then switch to default model for provider if ( - modelOptions[aiProvider].length && + modelOptions[aiProvider]?.length && !modelOptions[aiProvider].find((o) => o.value === aiModel) ) { setValue("aiModel", getDefaultModel(aiProvider)); diff --git a/apps/web/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection.tsx new file mode 100644 index 000000000..ce5a798ef --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useAction } from "next-safe-action/hooks"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { FormSection, FormSectionLeft } from "@/components/Form"; +import { resetAnalyticsAction } from "@/utils/actions/user"; +import { useAccount } from "@/providers/EmailAccountProvider"; + +export function ResetAnalyticsSection() { + const { emailAccountId } = useAccount(); + const { executeAsync: executeResetAnalytics } = useAction( + resetAnalyticsAction.bind(null, emailAccountId), + ); + + return ( + + + +
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx index bd9f4d2f8..4e5e35a18 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx @@ -11,6 +11,7 @@ import { WebhookSection } from "@/app/(app)/[emailAccountId]/settings/WebhookSec import prisma from "@/utils/prisma"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TabsToolbar } from "@/components/TabsToolbar"; +import { ResetAnalyticsSection } from "@/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection"; export default async function SettingsPage(props: { params: Promise<{ emailAccountId: string }>; @@ -31,7 +32,7 @@ export default async function SettingsPage(props: { if (!user) return

Email account not found

; return ( - +
@@ -48,6 +49,7 @@ export default async function SettingsPage(props: { {/* */} {/* */} + diff --git a/apps/web/utils/llms/config.ts b/apps/web/utils/llms/config.ts index 326aa7283..b98345992 100644 --- a/apps/web/utils/llms/config.ts +++ b/apps/web/utils/llms/config.ts @@ -32,6 +32,7 @@ export const Model = { }; export const providerOptions: { label: string; value: string }[] = [ + { label: "Default", value: DEFAULT_PROVIDER }, { label: "Anthropic", value: Provider.ANTHROPIC }, { label: "OpenAI", value: Provider.OPEN_AI }, { label: "Google", value: Provider.GOOGLE }, From 13e30130d9d357d2dc9f853324997e4e66381208 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 30 Apr 2025 05:48:25 -0400 Subject: [PATCH 159/176] fix build --- .../migration.sql | 8 ++++ apps/web/prisma/schema.prisma | 2 +- apps/web/utils/crypto.ts | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 apps/web/prisma/migrations/20250430094808_remove_cleanupjob_email/migration.sql create mode 100644 apps/web/utils/crypto.ts diff --git a/apps/web/prisma/migrations/20250430094808_remove_cleanupjob_email/migration.sql b/apps/web/prisma/migrations/20250430094808_remove_cleanupjob_email/migration.sql new file mode 100644 index 000000000..38bb8779c --- /dev/null +++ b/apps/web/prisma/migrations/20250430094808_remove_cleanupjob_email/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `CleanupJob` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "CleanupJob" DROP COLUMN "email"; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 51e0e666c..a0662a12e 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -475,7 +475,7 @@ model CleanupJob { skipAttachment Boolean? skipConversation Boolean? - email String // deprecated + // email String // deprecated emailAccountId String emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) diff --git a/apps/web/utils/crypto.ts b/apps/web/utils/crypto.ts new file mode 100644 index 000000000..61bdb936d --- /dev/null +++ b/apps/web/utils/crypto.ts @@ -0,0 +1,40 @@ +import crypto from "node:crypto"; +import { env } from "@/env"; + +const ALGORITHM = "aes-256-gcm"; +const SECRET_KEY = Buffer.from(env.OAUTH_LINK_STATE_SECRET, "hex"); // Ensure secret is 32 bytes hex encoded +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; + +export function encrypt(text: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, iv); + const encrypted = Buffer.concat([ + cipher.update(text, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + // Prepend IV and authTag to the encrypted data for storage + return Buffer.concat([iv, authTag, encrypted]).toString("hex"); +} + +export function decrypt(encryptedText: string): string | null { + try { + const buffer = Buffer.from(encryptedText, "hex"); + const iv = buffer.subarray(0, IV_LENGTH); + const authTag = buffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = buffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([ + decipher.update(encrypted), // No encoding here, it's a buffer + decipher.final(), + ]); + return decrypted.toString("utf8"); + } catch (error) { + // Log error appropriately if needed + console.error("Decryption failed:", error); + return null; + } +} From 904f179954f129778ede4e244dad53f7f51a6096 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:01:51 -0700 Subject: [PATCH 160/176] Add account confirm dialog --- apps/web/app/(app)/accounts/AddAccount.tsx | 39 +++++++++++++--------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index 41bfcfd31..c142c40eb 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -6,6 +6,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { toastError } from "@/components/Toast"; import Image from "next/image"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; export function AddAccount() { const [isLoading, setIsLoading] = useState(false); @@ -27,22 +28,28 @@ export function AddAccount() { return ( - + + + + {isLoading ? "Connecting..." : "Add Google Account"} + + + } + title="Connect Account" + description="Note: If your email address is already connected to another user, you will not be able to connect it to this user. We will offer a way to merge accounts in the near future." + confirmText="Got it" + onConfirm={async () => { + handleConnectGoogle(); + }} + /> ); From f91ead38895bfe87ac552afc237470d9bd7e7df9 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:02:45 -0700 Subject: [PATCH 161/176] remove crypto file --- apps/web/utils/crypto.ts | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 apps/web/utils/crypto.ts diff --git a/apps/web/utils/crypto.ts b/apps/web/utils/crypto.ts deleted file mode 100644 index 61bdb936d..000000000 --- a/apps/web/utils/crypto.ts +++ /dev/null @@ -1,40 +0,0 @@ -import crypto from "node:crypto"; -import { env } from "@/env"; - -const ALGORITHM = "aes-256-gcm"; -const SECRET_KEY = Buffer.from(env.OAUTH_LINK_STATE_SECRET, "hex"); // Ensure secret is 32 bytes hex encoded -const IV_LENGTH = 16; -const AUTH_TAG_LENGTH = 16; - -export function encrypt(text: string): string { - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, iv); - const encrypted = Buffer.concat([ - cipher.update(text, "utf8"), - cipher.final(), - ]); - const authTag = cipher.getAuthTag(); - // Prepend IV and authTag to the encrypted data for storage - return Buffer.concat([iv, authTag, encrypted]).toString("hex"); -} - -export function decrypt(encryptedText: string): string | null { - try { - const buffer = Buffer.from(encryptedText, "hex"); - const iv = buffer.subarray(0, IV_LENGTH); - const authTag = buffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); - const encrypted = buffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH); - - const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, iv); - decipher.setAuthTag(authTag); - const decrypted = Buffer.concat([ - decipher.update(encrypted), // No encoding here, it's a buffer - decipher.final(), - ]); - return decrypted.toString("utf8"); - } catch (error) { - // Log error appropriately if needed - console.error("Decryption failed:", error); - return null; - } -} From 547f8a4dc2453dfaaf399ce5b07b0ef2b3206a11 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:04:45 -0700 Subject: [PATCH 162/176] better security --- apps/web/app/api/v1/helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/v1/helpers.ts b/apps/web/app/api/v1/helpers.ts index 444faf4e6..68c6e1d5f 100644 --- a/apps/web/app/api/v1/helpers.ts +++ b/apps/web/app/api/v1/helpers.ts @@ -27,8 +27,8 @@ export async function getEmailAccountId({ if (!accountId) return undefined; - const emailAccount = await prisma.emailAccount.findFirst({ - where: { accountId }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { accountId, userId }, select: { email: true }, }); From 2d60d25a6e8efa866390e62fb82a23997fceb0eb Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:08:05 -0700 Subject: [PATCH 163/176] fix cold email get --- apps/web/app/api/user/cold-email/route.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/app/api/user/cold-email/route.ts b/apps/web/app/api/user/cold-email/route.ts index 5ee3aee6a..0871d04f1 100644 --- a/apps/web/app/api/user/cold-email/route.ts +++ b/apps/web/app/api/user/cold-email/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; +import { withEmailAccount } from "@/utils/middleware"; import { ColdEmailStatus } from "@prisma/client"; const LIMIT = 50; @@ -9,11 +8,14 @@ const LIMIT = 50; export type ColdEmailsResponse = Awaited>; async function getColdEmails( - { userId, status }: { userId: string; status: ColdEmailStatus }, + { + emailAccountId, + status, + }: { emailAccountId: string; status: ColdEmailStatus }, page: number, ) { const where = { - userId, + emailAccountId, status, }; @@ -39,9 +41,8 @@ async function getColdEmails( return { coldEmails, totalPages: Math.ceil(count / LIMIT) }; } -export const GET = withError(async (request) => { - const session = await auth(); - if (!session?.user) return NextResponse.json({ error: "Not authenticated" }); +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; const url = new URL(request.url); const page = Number.parseInt(url.searchParams.get("page") || "1"); @@ -49,7 +50,7 @@ export const GET = withError(async (request) => { (url.searchParams.get("status") as ColdEmailStatus | undefined) || ColdEmailStatus.AI_LABELED_COLD; - const result = await getColdEmails({ userId: session.user.id, status }, page); + const result = await getColdEmails({ emailAccountId, status }, page); return NextResponse.json(result); }); From 65b0b4043ecfefe0cb46c990e8044dacab4b2811 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 May 2025 05:09:02 -0700 Subject: [PATCH 164/176] use prefix path. fix more links --- .../automation/ProcessResultDisplay.tsx | 8 +- .../automation/ProcessRules.tsx | 8 +- .../automation/ReportMistake.tsx | 98 ++++++++++++++----- .../[emailAccountId]/automation/RuleForm.tsx | 19 +--- .../[emailAccountId]/automation/Rules.tsx | 20 +++- .../automation/TestCustomEmailForm.tsx | 5 +- .../automation/create/page.tsx | 5 +- .../automation/group/Groups.tsx | 44 +++++++-- .../automation/group/ViewGroup.tsx | 9 +- .../rule/[ruleId]/examples/page.tsx | 17 +++- .../[emailAccountId]/clean/CleanHistory.tsx | 5 +- apps/web/components/GroupedTable.tsx | 17 +++- apps/web/utils/actions/rule.ts | 21 ++-- 13 files changed, 203 insertions(+), 73 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/automation/ProcessResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ProcessResultDisplay.tsx index 04549ce7e..17e038268 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/ProcessResultDisplay.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ProcessResultDisplay.tsx @@ -8,13 +8,16 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { HoverCard } from "@/components/HoverCard"; import { Badge } from "@/components/Badge"; import { isAIRule } from "@/utils/condition"; +import { prefixPath } from "@/utils/path"; export function ProcessResultDisplay({ result, prefix, + emailAccountId, }: { result: RunRulesResult; prefix?: string; + emailAccountId: string; }) { if (!result) return null; @@ -84,7 +87,10 @@ export function ProcessResultDisplay({ Matched rule "{result.rule.name}" diff --git a/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx index e45498319..7e087f4d5 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/ProcessRules.tsx @@ -222,6 +222,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { result={results[message.id]} onRun={(rerun) => onRun(message, rerun)} testMode={testMode} + emailAccountId={emailAccountId} /> ))} @@ -252,6 +253,7 @@ function ProcessRulesRow({ result, onRun, testMode, + emailAccountId, }: { message: Message; userEmail: string; @@ -259,6 +261,7 @@ function ProcessRulesRow({ result: RunRulesResult; onRun: (rerun?: boolean) => void; testMode: boolean; + emailAccountId: string; }) { return ( Already processed )} - +
@@ -234,6 +236,7 @@ function Content({ return ( ); @@ -273,6 +276,7 @@ function Content({ if (isExpectedStaticRule || isActualStaticRule) { return ( ); } @@ -327,6 +333,7 @@ function Content({ } function AIFixView({ + emailAccountId, loadingAiFix, fixedInstructions, fixedInstructionsRule, @@ -335,6 +342,7 @@ function AIFixView({ onBack, onReject, }: { + emailAccountId: string; loadingAiFix: boolean; fixedInstructions: { ruleId: string; @@ -359,7 +367,10 @@ function AIFixView({
{fixedInstructionsRule?.instructions ? (
- + void; }) { return ( @@ -399,7 +412,10 @@ function RuleMismatch({
); @@ -575,7 +596,7 @@ function GroupMismatchRemove({
- +
); @@ -629,7 +650,7 @@ function CategoryMismatch({
- +
); @@ -637,10 +658,12 @@ function CategoryMismatch({ // TODO: Could auto fix the static rule for the user function StaticMismatch({ + emailAccountId, ruleId, isExpectedStaticRule, onBack, }: { + emailAccountId: string; ruleId: string; isExpectedStaticRule: boolean; onBack: () => void; @@ -656,7 +679,7 @@ function StaticMismatch({
- +
); @@ -669,6 +692,7 @@ function ManualFixView({ result, onBack, isTest, + emailAccountId, }: { actualRule?: Rule | null; expectedRule?: Rule | null; @@ -676,17 +700,27 @@ function ManualFixView({ result: RunRulesResult | null; onBack: () => void; isTest: boolean; + emailAccountId: string; }) { return ( <> <> - {result && } + {result && ( + + )} {actualRule && ( <> {isAIRule(actualRule) ? ( ) : ( - + )} @@ -698,7 +732,10 @@ function ManualFixView({ {isAIRule(expectedRule) ? ( ) : ( - + )} @@ -976,17 +1013,20 @@ function RerunButton({ const [result, setResult] = useState(); const { emailAccountId } = useAccount(); - const { execute, isExecuting } = useAction(runRulesAction.bind(null, emailAccountId), { - onSuccess: (result) => { - setResult(result?.data); - }, - onError: (error) => { - toastError({ - title: "There was an error testing the email", - description: error.error.serverError ?? "An error occurred", - }); + const { execute, isExecuting } = useAction( + runRulesAction.bind(null, emailAccountId), + { + onSuccess: (result) => { + setResult(result?.data); + }, + onError: (error) => { + toastError({ + title: "There was an error testing the email", + description: error.error.serverError ?? "An error occurred", + }); + }, }, - }); + ); return ( <> @@ -1007,7 +1047,10 @@ function RerunButton({ {result && (
Result: - +
)} @@ -1023,10 +1066,19 @@ function BackButton({ onBack }: { onBack: () => void }) { ); } -function EditRuleButton({ ruleId }: { ruleId: string }) { +function EditRuleButton({ + ruleId, + emailAccountId, +}: { + ruleId: string; + emailAccountId: string; +}) { return ( - )} */} - - + ) : ( diff --git a/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx b/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx index 4f9683cca..08f45f8f7 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/group/ViewGroup.tsx @@ -42,8 +42,10 @@ import { Badge } from "@/components/ui/badge"; import { formatShortDate } from "@/utils/date"; import { Tooltip } from "@/components/Tooltip"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; export function ViewGroup({ groupId }: { groupId: string }) { + const { emailAccountId } = useAccount(); const { data, isLoading, error, mutate } = useSWR( `/api/user/group/${groupId}/items`, ); @@ -86,7 +88,10 @@ export function ViewGroup({ groupId }: { groupId: string }) { {!!group?.items.length && ( } diff --git a/apps/web/app/(app)/[emailAccountId]/clean/CleanHistory.tsx b/apps/web/app/(app)/[emailAccountId]/clean/CleanHistory.tsx index aaf222fc4..5bdc8b44d 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/CleanHistory.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/CleanHistory.tsx @@ -5,8 +5,11 @@ import Link from "next/link"; import type { CleanHistoryResponse } from "@/app/api/clean/history/route"; import { LoadingContent } from "@/components/LoadingContent"; import { formatDateSimple } from "@/utils/date"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; export function CleanHistory() { + const { emailAccountId } = useAccount(); const { data, error, isLoading } = useSWR("/api/clean/history"); @@ -16,7 +19,7 @@ export function CleanHistory() {
{data.result.map((job) => ( diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 70456c53b..68db61a45 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -58,6 +58,7 @@ import type { CategoryWithRules } from "@/utils/category.server"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { CategorySelect } from "@/components/CategorySelect"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; const COLUMNS = 4; @@ -246,6 +247,7 @@ export function GroupedTable({ return ( {category.rules.map((rule) => ( ); diff --git a/apps/web/app/(app)/[emailAccountId]/settings/ApiKeysSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/ApiKeysSection.tsx index 9b22be5c0..e2c42e949 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/ApiKeysSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/ApiKeysSection.tsx @@ -1,6 +1,6 @@ -import { auth } from "@/app/api/auth/[...nextauth]/auth"; +"use client"; + import { FormSection, FormSectionLeft } from "@/components/Form"; -import prisma from "@/utils/prisma"; import { Table, TableBody, @@ -14,20 +14,11 @@ import { ApiKeysDeactivateButton, } from "@/app/(app)/[emailAccountId]/settings/ApiKeysCreateForm"; import { Card } from "@/components/ui/card"; +import { useApiKeys } from "@/hooks/useApiKeys"; +import { LoadingContent } from "@/components/LoadingContent"; -export async function ApiKeysSection() { - const session = await auth(); - const userId = session?.user.id; - if (!userId) throw new Error("Not authenticated"); - - const apiKeys = await prisma.apiKey.findMany({ - where: { userId, isActive: true }, - select: { - id: true, - name: true, - createdAt: true, - }, - }); +export function ApiKeysSection() { + const { data, isLoading, error, mutate } = useApiKeys(); return ( @@ -36,34 +27,39 @@ export async function ApiKeysSection() { description="Create an API key to access the Inbox Zero API. Do not share your API key with others, or expose it in the browser or other client-side code." /> -
- {apiKeys.length > 0 ? ( - -
- - - Name - Created - - - - - {apiKeys.map((apiKey) => ( - - {apiKey.name} - {apiKey.createdAt.toLocaleString()} - - - + +
+ {data && data.apiKeys.length > 0 ? ( + +
+ + + Name + Created + - ))} - -
- - ) : null} + + + {data.apiKeys.map((apiKey) => ( + + {apiKey.name} + {apiKey.createdAt.toLocaleString()} + + + + + ))} + + + + ) : null} - -
+ +
+ ); } diff --git a/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx index 635a73e4f..d8f8b13ad 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/DeleteSection.tsx @@ -7,12 +7,10 @@ import { FormSection, FormSectionLeft } from "@/components/Form"; import { deleteAccountAction } from "@/utils/actions/user"; import { logOut } from "@/utils/user"; import { useStatLoader } from "@/providers/StatLoaderProvider"; -import { useAccount } from "@/providers/EmailAccountProvider"; export function DeleteSection() { const { onCancelLoadBatch } = useStatLoader(); - const { emailAccountId } = useAccount(); const { executeAsync: executeDeleteAccount } = useAction( deleteAccountAction.bind(null), ); diff --git a/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx index 5760c40e4..b3e926e48 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx @@ -17,8 +17,10 @@ import { useAccount } from "@/providers/EmailAccountProvider"; export function EmailUpdatesSection({ statsEmailFrequency, + mutate, }: { statsEmailFrequency: Frequency; + mutate: () => void; }) { return ( @@ -27,12 +29,21 @@ export function EmailUpdatesSection({ description="Get a weekly digest of items that need your attention." /> - + ); } -function StatsUpdateSectionForm(props: { statsEmailFrequency: Frequency }) { +function StatsUpdateSectionForm({ + statsEmailFrequency, + mutate, +}: { + statsEmailFrequency: Frequency; + mutate: () => void; +}) { const { emailAccountId } = useAccount(); const { @@ -41,9 +52,7 @@ function StatsUpdateSectionForm(props: { statsEmailFrequency: Frequency }) { formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(saveEmailUpdateSettingsBody), - defaultValues: { - statsEmailFrequency: props.statsEmailFrequency, - }, + defaultValues: { statsEmailFrequency }, }); const onSubmit: SubmitHandler = useCallback( @@ -57,8 +66,10 @@ function StatsUpdateSectionForm(props: { statsEmailFrequency: Frequency }) { } else { toastSuccess({ description: "Settings updated!" }); } + + mutate(); }, - [emailAccountId], + [emailAccountId, mutate], ); const options: { label: string; value: Frequency }[] = useMemo( diff --git a/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx index bae925139..4a004819e 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/WebhookGenerate.tsx @@ -6,7 +6,13 @@ import { toastError, toastSuccess } from "@/components/Toast"; import { useAccount } from "@/providers/EmailAccountProvider"; import { useAction } from "next-safe-action/hooks"; -export function RegenerateSecretButton({ hasSecret }: { hasSecret: boolean }) { +export function RegenerateSecretButton({ + hasSecret, + mutate, +}: { + hasSecret: boolean; + mutate: () => void; +}) { const { emailAccountId } = useAccount(); const { execute, isExecuting } = useAction( regenerateWebhookSecretAction.bind(null, emailAccountId), @@ -23,6 +29,9 @@ export function RegenerateSecretButton({ hasSecret }: { hasSecret: boolean }) { "An unknown error occurred while regenerating the webhook secret", }); }, + onSettled: () => { + mutate(); + }, }, ); diff --git a/apps/web/app/(app)/[emailAccountId]/settings/WebhookSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/WebhookSection.tsx index 33c81c234..70855ef38 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/WebhookSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/WebhookSection.tsx @@ -1,13 +1,15 @@ +"use client"; + import { FormSection, FormSectionLeft } from "@/components/Form"; import { Card } from "@/components/ui/card"; import { CopyInput } from "@/components/CopyInput"; import { RegenerateSecretButton } from "@/app/(app)/[emailAccountId]/settings/WebhookGenerate"; +import { useUser } from "@/hooks/useUser"; +import { LoadingContent } from "@/components/LoadingContent"; + +export function WebhookSection() { + const { data, isLoading, error, mutate } = useUser(); -export async function WebhookSection({ - webhookSecret, -}: { - webhookSecret: string | null; -}) { return ( -
- {!!webhookSecret && } + + {data && ( +
+ {!!data.webhookSecret && ( + + )} - -
+ +
+ )} +
diff --git a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx index 4e5e35a18..a6e3dc930 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx @@ -1,5 +1,7 @@ +"use client"; + import { FormWrapper } from "@/components/Form"; -import { AboutSection } from "@/app/(app)/[emailAccountId]/settings/AboutSection"; +import { AboutSectionForm } from "@/app/(app)/[emailAccountId]/settings/AboutSectionForm"; // import { SignatureSectionForm } from "@/app/(app)/settings/SignatureSectionForm"; // import { LabelsSection } from "@/app/(app)/settings/LabelsSection"; import { DeleteSection } from "@/app/(app)/[emailAccountId]/settings/DeleteSection"; @@ -8,28 +10,16 @@ import { EmailUpdatesSection } from "@/app/(app)/[emailAccountId]/settings/Email import { MultiAccountSection } from "@/app/(app)/[emailAccountId]/settings/MultiAccountSection"; import { ApiKeysSection } from "@/app/(app)/[emailAccountId]/settings/ApiKeysSection"; import { WebhookSection } from "@/app/(app)/[emailAccountId]/settings/WebhookSection"; -import prisma from "@/utils/prisma"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TabsToolbar } from "@/components/TabsToolbar"; import { ResetAnalyticsSection } from "@/app/(app)/[emailAccountId]/settings/ResetAnalyticsSection"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { LoadingContent } from "@/components/LoadingContent"; -export default async function SettingsPage(props: { +export default function SettingsPage(_props: { params: Promise<{ emailAccountId: string }>; }) { - const params = await props.params; - const emailAccountId = params.emailAccountId; - - const user = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - about: true, - signature: true, - statsEmailFrequency: true, - webhookSecret: true, - }, - }); - - if (!user) return

Email account not found

; + const { data, isLoading, error, mutate } = useEmailAccountFull(); return ( @@ -43,20 +33,27 @@ export default async function SettingsPage(props: { - - - {/* this is only used in Gmail when sending a new message. disabling for now. */} - {/* */} - {/* */} - - - + + {data && ( + + + {/* this is only used in Gmail when sending a new message. disabling for now. */} + {/* */} + {/* */} + + + + )} + - + diff --git a/apps/web/app/api/user/api-keys/route.ts b/apps/web/app/api/user/api-keys/route.ts new file mode 100644 index 000000000..e7a0cbf8d --- /dev/null +++ b/apps/web/app/api/user/api-keys/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withAuth } from "@/utils/middleware"; + +export type ApiKeyResponse = Awaited>; + +async function getApiKeys({ userId }: { userId: string }) { + const apiKeys = await prisma.apiKey.findMany({ + where: { userId, isActive: true }, + select: { + id: true, + name: true, + createdAt: true, + }, + }); + + return { apiKeys }; +} + +export const GET = withAuth(async (request) => { + const userId = request.auth.userId; + + const apiKeys = await getApiKeys({ userId }); + + return NextResponse.json(apiKeys); +}); diff --git a/apps/web/app/api/user/email-account/route.ts b/apps/web/app/api/user/email-account/route.ts index 8ef1a92cf..2525ab0d2 100644 --- a/apps/web/app/api/user/email-account/route.ts +++ b/apps/web/app/api/user/email-account/route.ts @@ -3,7 +3,6 @@ import prisma from "@/utils/prisma"; import { withEmailAccount } from "@/utils/middleware"; import { SafeError } from "@/utils/error"; -// Should this path be renamed to email account instead of user? export type EmailAccountFullResponse = Awaited< ReturnType > | null; diff --git a/apps/web/app/api/user/me/route.ts b/apps/web/app/api/user/me/route.ts index 3c0c88826..7dbf7e244 100644 --- a/apps/web/app/api/user/me/route.ts +++ b/apps/web/app/api/user/me/route.ts @@ -4,7 +4,6 @@ import { withError } from "@/utils/middleware"; import { SafeError } from "@/utils/error"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -// Should this path be renamed to email account instead of user? export type UserResponse = Awaited> | null; async function getUser({ userId }: { userId: string }) { @@ -15,6 +14,7 @@ async function getUser({ userId }: { userId: string }) { aiProvider: true, aiModel: true, aiApiKey: true, + webhookSecret: true, premium: { select: { lemonSqueezyCustomerId: true, diff --git a/apps/web/hooks/useApiKeys.ts b/apps/web/hooks/useApiKeys.ts new file mode 100644 index 000000000..016360b76 --- /dev/null +++ b/apps/web/hooks/useApiKeys.ts @@ -0,0 +1,10 @@ +import useSWR from "swr"; +import type { ApiKeyResponse } from "@/app/api/user/api-keys/route"; +import { processSWRResponse } from "@/utils/swr"; + +export function useApiKeys() { + const swrResult = useSWR( + "/api/user/api-keys", + ); + return processSWRResponse(swrResult); +} diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index a0662a12e..c931c3fd3 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -74,7 +74,7 @@ model User { coldEmailBlocker ColdEmailSetting? // deprecated coldEmailPrompt String? // deprecated rulesPrompt String? // deprecated - webhookSecret String? // deprecated + webhookSecret String? outboundReplyTracking Boolean @default(false) // deprecated // categorization @@ -121,7 +121,7 @@ model EmailAccount { coldEmailBlocker ColdEmailSetting? coldEmailPrompt String? rulesPrompt String? - webhookSecret String? + webhookSecret String? // deprecated outboundReplyTracking Boolean @default(false) autoCategorizeSenders Boolean @default(false) diff --git a/apps/web/utils/actions/api-key.ts b/apps/web/utils/actions/api-key.ts index 5721ead3f..d69248fd3 100644 --- a/apps/web/utils/actions/api-key.ts +++ b/apps/web/utils/actions/api-key.ts @@ -1,6 +1,5 @@ "use server"; -import { revalidatePath } from "next/cache"; import { createApiKeyBody, deactivateApiKeyBody, @@ -25,8 +24,6 @@ export const createApiKeyAction = actionClientUser }, }); - revalidatePath("/settings"); - return { secretKey }; }); @@ -38,6 +35,4 @@ export const deactivateApiKeyAction = actionClientUser where: { id, userId }, data: { isActive: false }, }); - - revalidatePath("/settings"); }); diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index cac3f1ed9..061de9f6f 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -1,7 +1,6 @@ "use server"; import { z } from "zod"; -import { revalidatePath } from "next/cache"; import { after } from "next/server"; import { signOut } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; @@ -26,8 +25,6 @@ export const saveAboutAction = actionClient where: { id: emailAccountId }, data: { about }, }); - - revalidatePath("/settings"); }); const saveSignatureBody = z.object({ signature: z.string().max(2_000) }); @@ -41,8 +38,6 @@ export const saveSignatureAction = actionClient where: { id: emailAccountId }, data: { signature }, }); - - revalidatePath("/settings"); }); export const loadSignatureFromGmailAction = actionClient diff --git a/apps/web/utils/actions/webhook.ts b/apps/web/utils/actions/webhook.ts index 3b397a6a1..e98444d55 100644 --- a/apps/web/utils/actions/webhook.ts +++ b/apps/web/utils/actions/webhook.ts @@ -1,6 +1,5 @@ "use server"; -import { revalidatePath } from "next/cache"; import prisma from "@/utils/prisma"; import { actionClient } from "@/utils/actions/safe-action"; @@ -13,8 +12,6 @@ export const regenerateWebhookSecretAction = actionClient where: { id: emailAccountId }, data: { webhookSecret }, }); - - revalidatePath("/settings"); }); function generateWebhookSecret(length = 32) { From b63be385d99d2eb14ec5607cb57deb6bf5b5b3d5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 May 2025 05:45:33 -0700 Subject: [PATCH 166/176] revalidate fixes --- .../app/(app)/[emailAccountId]/automation/Rules.tsx | 1 + apps/web/utils/actions/categorize.ts | 13 +++++++------ apps/web/utils/actions/group.ts | 5 ----- apps/web/utils/actions/knowledge.ts | 7 ------- apps/web/utils/actions/reply-tracking.ts | 5 +++-- apps/web/utils/actions/rule.ts | 6 +----- .../utils/reply-tracker/check-previous-emails.ts | 3 ++- 7 files changed, 14 insertions(+), 26 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx index cce6e97a9..854be0fcf 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/Rules.tsx @@ -46,6 +46,7 @@ import { ThreadsExplanation } from "@/app/(app)/[emailAccountId]/automation/Rule import { useAction } from "next-safe-action/hooks"; import { useAccount } from "@/providers/EmailAccountProvider"; import { prefixPath } from "@/utils/path"; + export function Rules() { const { data, isLoading, error, mutate } = useRules(); diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 75623bbbe..422ea7323 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -24,6 +24,7 @@ import { getSenders } from "@/app/api/user/categorize/senders/uncategorized/get- import { extractEmailAddress } from "@/utils/email"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; +import { prefixPath } from "@/utils/path"; const logger = createScopedLogger("actions/categorize"); @@ -125,7 +126,7 @@ export const categorizeSenderAction = actionClient session.accessToken, ); - revalidatePath("/smart-categories"); + revalidatePath(prefixPath(emailAccountId, "/smart-categories")); return result; }, @@ -150,7 +151,7 @@ export const changeSenderCategoryAction = actionClient categoryId, }); - revalidatePath("/smart-categories"); + revalidatePath(prefixPath(emailAccountId, "/smart-categories")); }, ); @@ -183,7 +184,7 @@ export const upsertDefaultCategoriesAction = actionClient } } - revalidatePath("/smart-categories"); + revalidatePath(prefixPath(emailAccountId, "/smart-categories")); }); export const createCategoryAction = actionClient @@ -196,7 +197,7 @@ export const createCategoryAction = actionClient newCategory: { name, description }, }); - revalidatePath("/smart-categories"); + revalidatePath(prefixPath(emailAccountId, "/smart-categories")); }, ); @@ -206,7 +207,7 @@ export const deleteCategoryAction = actionClient .action(async ({ ctx: { emailAccountId }, parsedInput: { categoryId } }) => { await deleteCategory({ emailAccountId, categoryId }); - revalidatePath("/smart-categories"); + revalidatePath(prefixPath(emailAccountId, "/smart-categories")); }); async function deleteCategory({ @@ -286,6 +287,6 @@ export const removeAllFromCategoryAction = actionClient data: { categoryId: null }, }); - revalidatePath("/smart-categories"); + revalidatePath(prefixPath(emailAccountId, "/smart-categories")); }, ); diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index fab141ad3..05a8c0392 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -1,7 +1,6 @@ "use server"; import { z } from "zod"; -import { revalidatePath } from "next/cache"; import prisma from "@/utils/prisma"; import { addGroupItemBody, @@ -52,8 +51,6 @@ export const addGroupItemAction = actionClient }; await addGroupItem({ groupId, type, value }); - - revalidatePath("/automation"); }, ); @@ -62,6 +59,4 @@ export const deleteGroupItemAction = actionClient .schema(z.object({ id: z.string() })) .action(async ({ ctx: { emailAccountId }, parsedInput: { id } }) => { await deleteGroupItem({ id, emailAccountId }); - - revalidatePath("/automation"); }); diff --git a/apps/web/utils/actions/knowledge.ts b/apps/web/utils/actions/knowledge.ts index 415e3ddea..5ac28ffe9 100644 --- a/apps/web/utils/actions/knowledge.ts +++ b/apps/web/utils/actions/knowledge.ts @@ -1,6 +1,5 @@ "use server"; -import { revalidatePath } from "next/cache"; import prisma from "@/utils/prisma"; import { createKnowledgeBody, @@ -21,8 +20,6 @@ export const createKnowledgeAction = actionClient emailAccountId, }, }); - - revalidatePath("/automation"); }, ); @@ -38,8 +35,6 @@ export const updateKnowledgeAction = actionClient where: { id, emailAccountId }, data: { title, content }, }); - - revalidatePath("/automation"); }, ); @@ -50,6 +45,4 @@ export const deleteKnowledgeAction = actionClient await prisma.knowledge.delete({ where: { id, emailAccountId }, }); - - revalidatePath("/automation"); }); diff --git a/apps/web/utils/actions/reply-tracking.ts b/apps/web/utils/actions/reply-tracking.ts index a75527afa..430374896 100644 --- a/apps/web/utils/actions/reply-tracking.ts +++ b/apps/web/utils/actions/reply-tracking.ts @@ -14,6 +14,7 @@ import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; import { SafeError } from "@/utils/error"; import { getEmailAccountWithAi } from "@/utils/user/get"; +import { prefixPath } from "@/utils/path"; const logger = createScopedLogger("enableReplyTracker"); @@ -22,7 +23,7 @@ export const enableReplyTrackerAction = actionClient .action(async ({ ctx: { emailAccountId } }) => { await enableReplyTracker({ emailAccountId }); - revalidatePath("/reply-zero"); + revalidatePath(prefixPath(emailAccountId, "/reply-zero")); return { success: true }; }); @@ -68,7 +69,7 @@ export const resolveThreadTrackerAction = actionClient logger.error("Error stopping Reply Zero analysis", { error }); }); - revalidatePath("/reply-zero"); + revalidatePath(prefixPath(emailAccountId, "/reply-zero")); return { success: true }; }, diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index c42003cc3..0dd851b94 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -111,8 +111,6 @@ export const createRuleAction = actionClient await updatePromptFileOnRuleCreated({ emailAccountId, rule }); - revalidatePath("/automation"); - return { rule }; } catch (error) { if (isDuplicateError(error, "name")) { @@ -390,9 +388,7 @@ export const deleteRuleAction = actionClient where: { id: emailAccountId }, data: { rulesPrompt: updatedPrompt }, }); - - revalidatePath(`/automation/rule/${id}`); - revalidatePath("/automation"); + revalidatePath(prefixPath(emailAccountId, `/automation/rule/${id}`)); } catch (error) { if (isNotFoundError(error)) return; throw error; diff --git a/apps/web/utils/reply-tracker/check-previous-emails.ts b/apps/web/utils/reply-tracker/check-previous-emails.ts index a35ce7c0a..ba771d6ca 100644 --- a/apps/web/utils/reply-tracker/check-previous-emails.ts +++ b/apps/web/utils/reply-tracker/check-previous-emails.ts @@ -8,6 +8,7 @@ import type { EmailAccountWithAI } from "@/utils/llms/types"; import { handleInboundReply } from "@/utils/reply-tracker/inbound"; import { getAssistantEmail } from "@/utils/assistant/is-assistant-email"; import prisma from "@/utils/prisma"; +import { prefixPath } from "@/utils/path"; const logger = createScopedLogger("reply-tracker/check-previous-emails"); @@ -87,7 +88,7 @@ export async function processPreviousSentEmails({ }); } - revalidatePath("/reply-zero"); + revalidatePath(prefixPath(emailAccount.id, "/reply-zero")); } catch (error) { logger.error("Error processing message for reply tracking", { ...loggerOptions, From 0e12281267d72ea1502f783c983141acb7ff61b2 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 May 2025 05:49:39 -0700 Subject: [PATCH 167/176] fix settings bug --- .../settings/EmailUpdatesSection.tsx | 16 ++++++++-------- .../app/(app)/[emailAccountId]/settings/page.tsx | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx b/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx index b3e926e48..4765b6f87 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/EmailUpdatesSection.tsx @@ -16,10 +16,10 @@ import { updateEmailSettingsAction } from "@/utils/actions/settings"; import { useAccount } from "@/providers/EmailAccountProvider"; export function EmailUpdatesSection({ - statsEmailFrequency, + summaryEmailFrequency, mutate, }: { - statsEmailFrequency: Frequency; + summaryEmailFrequency: Frequency; mutate: () => void; }) { return ( @@ -29,19 +29,19 @@ export function EmailUpdatesSection({ description="Get a weekly digest of items that need your attention." /> - ); } -function StatsUpdateSectionForm({ - statsEmailFrequency, +function SummaryUpdateSectionForm({ + summaryEmailFrequency, mutate, }: { - statsEmailFrequency: Frequency; + summaryEmailFrequency: Frequency; mutate: () => void; }) { const { emailAccountId } = useAccount(); @@ -52,7 +52,7 @@ function StatsUpdateSectionForm({ formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(saveEmailUpdateSettingsBody), - defaultValues: { statsEmailFrequency }, + defaultValues: { summaryEmailFrequency }, }); const onSubmit: SubmitHandler = useCallback( diff --git a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx index a6e3dc930..c75b5171e 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/page.tsx @@ -41,7 +41,7 @@ export default function SettingsPage(_props: { {/* */} {/* */} From b9cdc6b0da752080e3f3bb31a38d4387d5e91283 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 May 2025 06:03:32 -0700 Subject: [PATCH 168/176] fix tests --- apps/web/utils/api-auth.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/utils/api-auth.test.ts b/apps/web/utils/api-auth.test.ts index e63eff782..348c73522 100644 --- a/apps/web/utils/api-auth.test.ts +++ b/apps/web/utils/api-auth.test.ts @@ -267,7 +267,6 @@ describe("api-auth", () => { accessToken: "access-token", refreshToken: "refresh-token", expiresAt: 1234567890, - emailAccountId: "google-account-id", }); }); }); From 0ac9cfef6c1fcbde6df734fe9f948d00bc1706ef Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 May 2025 14:25:59 -0700 Subject: [PATCH 169/176] merge accounts --- apps/web/app/(app)/accounts/AddAccount.tsx | 78 ++++++-- .../app/api/google/linking/auth-url/route.ts | 39 ++++ .../app/api/google/linking/callback/route.ts | 166 ++++++++++++++++++ apps/web/utils/gmail/client.ts | 8 + apps/web/utils/gmail/constants.ts | 2 + 5 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 apps/web/app/api/google/linking/auth-url/route.ts create mode 100644 apps/web/app/api/google/linking/callback/route.ts diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index c142c40eb..1786d908f 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -6,7 +6,13 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { toastError } from "@/components/Toast"; import Image from "next/image"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { DialogTitle } from "@/components/ui/dialog"; +import { DialogHeader } from "@/components/ui/dialog"; +import { DialogContent } from "@/components/ui/dialog"; +import { DialogTrigger } from "@/components/ui/dialog"; +import { Dialog } from "@/components/ui/dialog"; +import type { GetAuthLinkUrlResponse } from "@/app/api/google/linking/auth-url/route"; export function AddAccount() { const [isLoading, setIsLoading] = useState(false); @@ -21,15 +27,38 @@ export function AddAccount() { title: "Error initiating Google link", description: "Please try again or contact support", }); - setIsLoading(false); } + setIsLoading(false); + }; + + const handleMergeGoogle = async () => { + setIsLoading(true); + try { + const response = await fetch("/api/google/linking/auth-url", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const data: GetAuthLinkUrlResponse = await response.json(); + + window.location.href = data.url; + } catch (error) { + console.error("Error initiating Google link:", error); + toastError({ + title: "Error initiating Google link", + description: "Please try again or contact support", + }); + } + setIsLoading(false); }; return ( - + - } - title="Connect Account" - description="Note: If your email address is already connected to another user, you will not be able to connect it to this user. We will offer a way to merge accounts in the near future." - confirmText="Got it" - onConfirm={async () => { - handleConnectGoogle(); - }} - /> + + + + Add or Merge Google Account + + Choose an action: +
    +
  • + Connect Account: Add an account that you haven't yet + added to Inbox Zero. +
  • +
  • + Merge Another Account: Sign in with a Google account + that's currently linked to a *different* Inbox Zero user. +
  • +
+
+
+ + + + + +
+
); diff --git a/apps/web/app/api/google/linking/auth-url/route.ts b/apps/web/app/api/google/linking/auth-url/route.ts new file mode 100644 index 000000000..da1e33812 --- /dev/null +++ b/apps/web/app/api/google/linking/auth-url/route.ts @@ -0,0 +1,39 @@ +import crypto from "node:crypto"; +import { NextResponse } from "next/server"; +import { withAuth } from "@/utils/middleware"; +import { getLinkingOAuth2Client } from "@/utils/gmail/client"; +import { GOOGLE_LINKING_STATE_COOKIE_NAME } from "@/utils/gmail/constants"; + +export type GetAuthLinkUrlResponse = { url: string }; + +export const getAuthUrl = ({ userId }: { userId: string }) => { + const googleAuth = getLinkingOAuth2Client(); + + const stateObject = { userId, nonce: crypto.randomUUID() }; + const state = Buffer.from(JSON.stringify(stateObject)).toString("base64url"); + + const url = googleAuth.generateAuthUrl({ + access_type: "offline", + scope: "openid email", + state, + }); + + return { url, state }; +}; + +export const GET = withAuth(async (request) => { + const userId = request.auth.userId; + const { url, state } = getAuthUrl({ userId }); + + const response = NextResponse.json({ url }); + + response.cookies.set(GOOGLE_LINKING_STATE_COOKIE_NAME, state, { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + maxAge: 60 * 10, + path: "/", + sameSite: "lax", + }); + + return response; +}); diff --git a/apps/web/app/api/google/linking/callback/route.ts b/apps/web/app/api/google/linking/callback/route.ts new file mode 100644 index 000000000..1c0b5ac20 --- /dev/null +++ b/apps/web/app/api/google/linking/callback/route.ts @@ -0,0 +1,166 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { env } from "@/env"; +import prisma from "@/utils/prisma"; +import { createScopedLogger } from "@/utils/logger"; +import { getLinkingOAuth2Client } from "@/utils/gmail/client"; +import { GOOGLE_LINKING_STATE_COOKIE_NAME } from "@/utils/gmail/constants"; +import { withError } from "@/utils/middleware"; + +const logger = createScopedLogger("google/linking/callback"); + +export const GET = withError(async (request: NextRequest) => { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + const receivedState = searchParams.get("state"); + const storedState = request.cookies.get( + GOOGLE_LINKING_STATE_COOKIE_NAME, + )?.value; + + const redirectUrl = new URL("/accounts", request.nextUrl.origin); + const response = NextResponse.redirect(redirectUrl); + + if (!storedState || !receivedState || storedState !== receivedState) { + logger.warn("Invalid state during Google linking callback", { + receivedState, + hasStoredState: !!storedState, + }); + redirectUrl.searchParams.set("error", "invalid_state"); + response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + let decodedState: { userId: string; intent: string; nonce: string }; + try { + decodedState = JSON.parse( + Buffer.from(storedState, "base64url").toString("utf8"), + ); + } catch (error) { + logger.error("Failed to decode state", { error }); + redirectUrl.searchParams.set("error", "invalid_state_format"); + response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME); + + const { userId: targetUserId } = decodedState; + + if (!code) { + logger.warn("Missing code in Google linking callback"); + redirectUrl.searchParams.set("error", "missing_code"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + const googleAuth = getLinkingOAuth2Client(); + + try { + const { tokens } = await googleAuth.getToken(code); + const { id_token } = tokens; + + if (!id_token) { + throw new Error("Missing id_token from Google response"); + } + + let payload: any; + try { + const ticket = await googleAuth.verifyIdToken({ + idToken: id_token, + audience: env.GOOGLE_CLIENT_ID, + }); + const verifiedPayload = ticket.getPayload(); + if (!verifiedPayload) { + throw new Error("Could not get payload from verified ID token ticket."); + } + payload = verifiedPayload; + } catch (err: any) { + logger.error("ID token verification failed using googleAuth:", err); + throw new Error(`ID token verification failed: ${err.message}`); + } + + const providerAccountId = payload.sub; + const providerEmail = payload.email; + + if (!providerAccountId || !providerEmail) { + throw new Error( + "ID token missing required subject (sub) or email claim.", + ); + } + + const existingAccount = await prisma.account.findUnique({ + where: { + provider_providerAccountId: { provider: "google", providerAccountId }, + }, + select: { + id: true, + userId: true, + user: { select: { name: true, email: true } }, + }, + }); + + if (!existingAccount) { + logger.warn( + `Merge Failed: Google account ${providerEmail} (${providerAccountId}) not found in the system. Cannot merge.`, + ); + redirectUrl.searchParams.set("error", "account_not_found_for_merge"); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } + + if (existingAccount.userId === targetUserId) { + logger.warn( + `Google account ${providerEmail} (${providerAccountId}) is already linked to the correct user ${targetUserId}. Merge action unnecessary.`, + ); + redirectUrl.searchParams.set("error", "already_linked_to_self"); + return NextResponse.redirect(redirectUrl, { + headers: response.headers, + }); + } + + logger.info( + `Merging Google account ${providerEmail} (${providerAccountId}) linked to user ${existingAccount.userId}, merging into ${targetUserId}.`, + ); + await prisma.$transaction([ + prisma.account.update({ + where: { id: existingAccount.id }, + data: { userId: targetUserId }, + }), + prisma.emailAccount.update({ + where: { accountId: existingAccount.id }, + data: { + userId: targetUserId, + name: existingAccount.user.name, + email: existingAccount.user.email, + }, + }), + prisma.user.delete({ + where: { id: existingAccount.userId }, + }), + ]); + + logger.info( + `Account ${providerAccountId} re-assigned to user ${targetUserId}. Original user was ${existingAccount.userId}`, + ); + redirectUrl.searchParams.set("success", "account_merged"); + return NextResponse.redirect(redirectUrl, { + headers: response.headers, + }); + } catch (error: any) { + logger.error("Error in Google linking callback:", { error }); + let errorCode = "link_failed"; + if (error.message?.includes("ID token verification failed")) { + errorCode = "invalid_id_token"; + } else if (error.message?.includes("Missing id_token")) { + errorCode = "missing_id_token"; + } else if (error.message?.includes("ID token missing required")) { + errorCode = "incomplete_id_token"; + } else if (error.message?.includes("Missing access_token")) { + errorCode = "token_exchange_failed"; + } + redirectUrl.searchParams.set("error", errorCode); + redirectUrl.searchParams.set( + "error_description", + error.message || "Unknown error", + ); + response.cookies.delete(GOOGLE_LINKING_STATE_COOKIE_NAME); + return NextResponse.redirect(redirectUrl, { headers: response.headers }); + } +}); diff --git a/apps/web/utils/gmail/client.ts b/apps/web/utils/gmail/client.ts index b72b263eb..620448294 100644 --- a/apps/web/utils/gmail/client.ts +++ b/apps/web/utils/gmail/client.ts @@ -36,6 +36,14 @@ const getAuth = ({ return googleAuth; }; +export function getLinkingOAuth2Client() { + return new auth.OAuth2({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + redirectUri: `${env.NEXT_PUBLIC_BASE_URL}/api/google/linking/callback`, + }); +} + // we should potentially use this everywhere instead of getGmailClient as this handles refreshing the access token and saving it to the db export const getGmailClientWithRefresh = async ({ accessToken, diff --git a/apps/web/utils/gmail/constants.ts b/apps/web/utils/gmail/constants.ts index 47e69d809..debd3795c 100644 --- a/apps/web/utils/gmail/constants.ts +++ b/apps/web/utils/gmail/constants.ts @@ -12,3 +12,5 @@ export const labelVisibility = { } as const; export type LabelVisibility = (typeof labelVisibility)[keyof typeof labelVisibility]; + +export const GOOGLE_LINKING_STATE_COOKIE_NAME = "google_linking_state"; From fd91dd67253e6d93a0ced0343e6d8c7271c645b4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 May 2025 14:39:02 -0700 Subject: [PATCH 170/176] fix up account switcher --- apps/web/components/AccountSwitcher.tsx | 39 +++++++++++++------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index 093a60242..fa1e5ac67 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -3,7 +3,6 @@ import { useMemo, useCallback } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; -import Image from "next/image"; import { useHotkeys } from "react-hotkeys-hook"; import { ChevronsUpDown, Plus } from "lucide-react"; import { @@ -25,15 +24,14 @@ import { useAccounts } from "@/hooks/useAccounts"; import type { GetEmailAccountsResponse } from "@/app/api/user/email-accounts/route"; import { useModifierKey } from "@/hooks/useModifierKey"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; export function AccountSwitcher() { const { data: accountsData } = useAccounts(); - return ( - - ); + if (!accountsData) return null; + + return ; } export function AccountSwitcherInternal({ @@ -55,7 +53,7 @@ export function AccountSwitcherInternal({ const getHref = useCallback( (emailAccountId: string) => { - if (!activeEmailAccountId) return `/${emailAccountId}`; + if (!activeEmailAccountId) return `/${emailAccountId}/setup`; const basePath = pathname.split("?")[0] || "/"; const newBasePath = basePath.replace( @@ -86,7 +84,10 @@ export function AccountSwitcherInternal({ {activeEmailAccount ? ( <>
- +
@@ -106,7 +107,7 @@ export function AccountSwitcherInternal({ ( - +
{emailAccount.name || emailAccount.email} @@ -155,21 +159,18 @@ export function AccountSwitcherInternal({ function ProfileImage({ image, + email = "", size = 24, }: { image: string | null; + email: string; size?: number; }) { - if (!image) return null; - return ( - + + + {email.at(0)?.toUpperCase()} + ); } From 968cecb50a1e6188694bf120d5f8116c2deb2583 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 1 May 2025 15:17:16 -0700 Subject: [PATCH 171/176] adjust top right --- apps/web/components/AccountSwitcher.tsx | 25 +++++-------------------- apps/web/components/ProfileImage.tsx | 18 ++++++++++++++++++ apps/web/components/TopNav.tsx | 25 +++++++++---------------- 3 files changed, 32 insertions(+), 36 deletions(-) create mode 100644 apps/web/components/ProfileImage.tsx diff --git a/apps/web/components/AccountSwitcher.tsx b/apps/web/components/AccountSwitcher.tsx index fa1e5ac67..f9e487598 100644 --- a/apps/web/components/AccountSwitcher.tsx +++ b/apps/web/components/AccountSwitcher.tsx @@ -24,7 +24,7 @@ import { useAccounts } from "@/hooks/useAccounts"; import type { GetEmailAccountsResponse } from "@/app/api/user/email-accounts/route"; import { useModifierKey } from "@/hooks/useModifierKey"; import { useAccount } from "@/providers/EmailAccountProvider"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { ProfileImage } from "@/components/ProfileImage"; export function AccountSwitcher() { const { data: accountsData } = useAccounts(); @@ -86,7 +86,9 @@ export function AccountSwitcherInternal({
@@ -120,7 +122,7 @@ export function AccountSwitcherInternal({
@@ -157,23 +159,6 @@ export function AccountSwitcherInternal({ ); } -function ProfileImage({ - image, - email = "", - size = 24, -}: { - image: string | null; - email: string; - size?: number; -}) { - return ( - - - {email.at(0)?.toUpperCase()} - - ); -} - function useAccountHotkeys( emailAccounts: GetEmailAccountsResponse["emailAccounts"], getHref: (emailAccountId: string) => string, diff --git a/apps/web/components/ProfileImage.tsx b/apps/web/components/ProfileImage.tsx new file mode 100644 index 000000000..a2dae87ae --- /dev/null +++ b/apps/web/components/ProfileImage.tsx @@ -0,0 +1,18 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +export function ProfileImage({ + image, + label = "", + size = 24, +}: { + image: string | null; + label: string; + size?: number; +}) { + return ( + + + {label.at(0)?.toUpperCase()} + + ); +} diff --git a/apps/web/components/TopNav.tsx b/apps/web/components/TopNav.tsx index faf14b2e5..3ad9ada2a 100644 --- a/apps/web/components/TopNav.tsx +++ b/apps/web/components/TopNav.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import Image from "next/image"; import { useSession, signIn } from "next-auth/react"; import { Menu, @@ -24,13 +23,12 @@ import { cn } from "@/utils"; import { ThemeToggle } from "@/components/theme-toggle"; import { prefixPath } from "@/utils/path"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { ProfileImage } from "@/components/ProfileImage"; export function TopNav({ trigger }: { trigger: React.ReactNode }) { return (
{trigger} - {/* Separator */} -