From 19cfa6ada71f51364734d36ab8696d3b3a55a540 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 22 Oct 2024 17:29:05 -0400 Subject: [PATCH 01/47] Find batch of senders --- .../BulkUnsubscribeMobile.tsx | 1 - .../categorise/senders/categorise-senders.ts | 1 + .../user/categorise/senders/find-senders.ts | 33 +++++++++++++++++++ apps/web/utils/gmail/message.ts | 2 +- apps/web/utils/gmail/thread.ts | 21 +++++++++++- 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/api/user/categorise/senders/categorise-senders.ts create mode 100644 apps/web/app/api/user/categorise/senders/find-senders.ts diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx index 57da561c3..5387b507b 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx +++ b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx @@ -26,7 +26,6 @@ import type { RowProps } from "@/app/(app)/bulk-unsubscribe/types"; import { Button } from "@/components/ui/button"; import { ButtonLoader } from "@/components/Loading"; import { NewsletterStatus } from "@prisma/client"; -import { cleanUnsubscribeLink } from "@/utils/parse/parseHtml.client"; import { Badge } from "@/components/ui/badge"; export function BulkUnsubscribeMobile({ diff --git a/apps/web/app/api/user/categorise/senders/categorise-senders.ts b/apps/web/app/api/user/categorise/senders/categorise-senders.ts new file mode 100644 index 000000000..bf5a0b2c7 --- /dev/null +++ b/apps/web/app/api/user/categorise/senders/categorise-senders.ts @@ -0,0 +1 @@ +export async function categoriseSenders() {} diff --git a/apps/web/app/api/user/categorise/senders/find-senders.ts b/apps/web/app/api/user/categorise/senders/find-senders.ts new file mode 100644 index 000000000..01bf949d0 --- /dev/null +++ b/apps/web/app/api/user/categorise/senders/find-senders.ts @@ -0,0 +1,33 @@ +import type { gmail_v1 } from "@googleapis/gmail"; +import { extractEmailAddress } from "@/utils/email"; +import { getMessage } from "@/utils/gmail/message"; +import { getThreadsWithNextPageToken } from "@/utils/gmail/thread"; +import type { MessageWithPayload } from "@/utils/types"; + +export async function findSenders(gmail: gmail_v1.Gmail) { + const senders = new Set(); + + const { threads, nextPageToken } = await getThreadsWithNextPageToken( + `-in:sent`, + [], + gmail, + ); + + for (const thread of threads) { + const firstMessage = thread.messages?.[0]; + if (!firstMessage?.id) continue; + const message = await getMessage(firstMessage.id, gmail, "metadata"); + + const sender = extractSenderInfo(message); + if (sender) senders.add(sender); + } + + return { senders: Array.from(senders), nextPageToken }; +} + +function extractSenderInfo(message: MessageWithPayload) { + const fromHeader = message.payload?.headers?.find((h) => h.name === "From"); + if (!fromHeader?.value) return null; + + return extractEmailAddress(fromHeader.value); +} diff --git a/apps/web/utils/gmail/message.ts b/apps/web/utils/gmail/message.ts index 6ca5d9e38..fba927554 100644 --- a/apps/web/utils/gmail/message.ts +++ b/apps/web/utils/gmail/message.ts @@ -13,7 +13,7 @@ import { extractDomainFromEmail } from "@/utils/email"; export async function getMessage( messageId: string, gmail: gmail_v1.Gmail, - format?: "full", + format?: "full" | "metadata", ): Promise { const message = await gmail.users.messages.get({ userId: "me", diff --git a/apps/web/utils/gmail/thread.ts b/apps/web/utils/gmail/thread.ts index c23529f22..61be304a0 100644 --- a/apps/web/utils/gmail/thread.ts +++ b/apps/web/utils/gmail/thread.ts @@ -18,7 +18,26 @@ export async function getThreads( labelIds, maxResults, }); - return threads.data; + return threads.data || []; +} + +export async function getThreadsWithNextPageToken( + q: string, + labelIds: string[], + gmail: gmail_v1.Gmail, + maxResults = 100, +) { + const threads = await gmail.users.threads.list({ + userId: "me", + q, + labelIds, + maxResults, + }); + + return { + threads: threads.data.threads || [], + nextPageToken: threads.data.nextPageToken, + }; } export async function getThreadsBatch( From 0e17cb1a8bc90aa4ed3b8bd2a66383a39977ebb7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 22 Oct 2024 17:32:15 -0400 Subject: [PATCH 02/47] paginate through senders --- .../user/categorise/senders/find-senders.ts | 29 ++++++++++++++++++- apps/web/utils/gmail/thread.ts | 2 ++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/user/categorise/senders/find-senders.ts b/apps/web/app/api/user/categorise/senders/find-senders.ts index 01bf949d0..ab955ba52 100644 --- a/apps/web/app/api/user/categorise/senders/find-senders.ts +++ b/apps/web/app/api/user/categorise/senders/find-senders.ts @@ -4,13 +4,40 @@ import { getMessage } from "@/utils/gmail/message"; import { getThreadsWithNextPageToken } from "@/utils/gmail/thread"; import type { MessageWithPayload } from "@/utils/types"; -export async function findSenders(gmail: gmail_v1.Gmail) { +export async function findSendersWithPagination( + gmail: gmail_v1.Gmail, + maxPages: number, +) { + const allSenders = new Set(); + let nextPageToken: string | undefined = undefined; + let currentPage = 0; + + while (currentPage < maxPages) { + const { senders, nextPageToken: newNextPageToken } = await findSenders( + gmail, + nextPageToken, + ); + + senders.forEach((sender) => allSenders.add(sender)); + + if (!newNextPageToken) break; // No more pages + + nextPageToken = newNextPageToken; + currentPage++; + } + + return Array.from(allSenders); +} + +async function findSenders(gmail: gmail_v1.Gmail, pageToken?: string) { const senders = new Set(); const { threads, nextPageToken } = await getThreadsWithNextPageToken( `-in:sent`, [], gmail, + 100, + pageToken, ); for (const thread of threads) { diff --git a/apps/web/utils/gmail/thread.ts b/apps/web/utils/gmail/thread.ts index 61be304a0..cebf110ee 100644 --- a/apps/web/utils/gmail/thread.ts +++ b/apps/web/utils/gmail/thread.ts @@ -26,12 +26,14 @@ export async function getThreadsWithNextPageToken( labelIds: string[], gmail: gmail_v1.Gmail, maxResults = 100, + pageToken?: string, ) { const threads = await gmail.users.threads.list({ userId: "me", q, labelIds, maxResults, + pageToken, }); return { From b780aa0fbe34a007c51aad802ab537e3e95c7d13 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:50:07 -0400 Subject: [PATCH 03/47] add ai categorise sender function --- .../__tests__/ai-categorise-senders.test.ts | 78 ++++++++++++ .../categorise/senders/categorise-sender.ts | 115 ++++++++++++++++++ .../user/categorise/senders/find-senders.ts | 15 ++- .../app/api/user/categorise/senders/types.ts | 3 + .../ai-categorise-senders.ts | 76 ++++++++++++ 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 apps/web/__tests__/ai-categorise-senders.test.ts create mode 100644 apps/web/app/api/user/categorise/senders/categorise-sender.ts create mode 100644 apps/web/app/api/user/categorise/senders/types.ts create mode 100644 apps/web/utils/ai/categorise-sender/ai-categorise-senders.ts diff --git a/apps/web/__tests__/ai-categorise-senders.test.ts b/apps/web/__tests__/ai-categorise-senders.test.ts new file mode 100644 index 000000000..ac06389cf --- /dev/null +++ b/apps/web/__tests__/ai-categorise-senders.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from "vitest"; +import { aiCategoriseSenders } from "@/utils/ai/categorise-sender/ai-categorise-senders"; +import { SenderCategory } from "@/app/api/user/categorise/senders/categorise-sender"; + +vi.mock("server-only", () => ({})); + +describe("aiCategoriseSenders", () => { + const user = { + email: "user@test.com", + aiProvider: null, + aiModel: null, + aiApiKey: null, + }; + + it("should categorize senders using AI", async () => { + const senders = [ + "newsletter@company.com", + "support@service.com", + "unknown@example.com", + "sales@business.com", + "noreply@socialnetwork.com", + ]; + + const result = await aiCategoriseSenders({ user, senders }); + + expect(result).toHaveLength(senders.length); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sender: expect.any(String), + category: expect.any(String), + }), + ]), + ); + + // Check specific senders + const newsletterResult = result.find( + (r) => r.sender === "newsletter@company.com", + ); + expect(newsletterResult?.category).toBe("newsletter"); + + const supportResult = result.find( + (r) => r.sender === "support@service.com", + ); + expect(supportResult?.category).toBe("support"); + + // The unknown sender might be categorized as "request_more_information" + const unknownResult = result.find( + (r) => r.sender === "unknown@example.com", + ); + expect(unknownResult?.category).toBe("request_more_information"); + }, 15_000); // Increased timeout for AI call + + it("should handle empty senders list", async () => { + const senders: string[] = []; + + const result = await aiCategoriseSenders({ user, senders }); + + expect(result).toEqual([]); + }); + + it("should categorize senders for all valid SenderCategory values", async () => { + const senders = Object.values(SenderCategory) + .filter((category) => category !== "unknown") + .map((category) => `${category}@example.com`); + + const result = await aiCategoriseSenders({ user, senders }); + + expect(result).toHaveLength(senders.length); + + for (const sender of senders) { + const category = sender.split("@")[0]; + const senderResult = result.find((r) => r.sender === sender); + expect(senderResult).toBeDefined(); + expect(senderResult?.category).toBe(category); + } + }, 15_000); +}); diff --git a/apps/web/app/api/user/categorise/senders/categorise-sender.ts b/apps/web/app/api/user/categorise/senders/categorise-sender.ts new file mode 100644 index 000000000..ec36b45a8 --- /dev/null +++ b/apps/web/app/api/user/categorise/senders/categorise-sender.ts @@ -0,0 +1,115 @@ +export const SenderCategory = { + UNKNOWN: "unknown", + NEWSLETTER: "newsletter", + MARKETING: "marketing", + RECEIPT: "receipt", + FINANCE: "finance", + LEGAL: "legal", + SUPPORT: "support", + PERSONAL: "personal", + WORK: "work", + SOCIAL: "social", + TRANSACTIONAL: "transactional", + EDUCATIONAL: "educational", + TRAVEL: "travel", + HEALTH: "health", + GOVERNMENT: "government", + CHARITY: "charity", + ENTERTAINMENT: "entertainment", +} as const; + +type SenderCategory = (typeof SenderCategory)[keyof typeof SenderCategory]; + +interface CategoryRule { + category: SenderCategory; + patterns: RegExp[]; + keywords?: string[]; +} + +const rules: CategoryRule[] = [ + { + category: SenderCategory.NEWSLETTER, + patterns: [/newsletter@/i, /updates@/i, /weekly@/i, /digest@/i], + keywords: ["subscribe", "unsubscribe", "newsletter", "digest"], + }, + { + category: SenderCategory.RECEIPT, + patterns: [/receipt@/i, /order@/i, /purchase@/i, /transaction@/i], + keywords: [ + "receipt", + "order confirmation", + "purchase", + "transaction", + "your order", + "invoice", + "payment confirmation", + ], + }, + { + category: SenderCategory.MARKETING, + patterns: [/marketing@/i, /promotions@/i, /offers@/i, /sales@/i], + keywords: ["offer", "discount", "sale", "limited time", "exclusive"], + }, + // { + // category: SenderCategory.CUSTOMER_SERVICE, + // patterns: [/support@/i, /help@/i, /customerservice@/i, /care@/i], + // keywords: ["ticket", "case", "inquiry", "support"], + // }, + { + category: SenderCategory.LEGAL, + patterns: [/legal@/i, /compliance@/i, /notices@/i], + keywords: ["agreement", "terms", "policy", "compliance"], + }, + { + category: SenderCategory.FINANCE, + patterns: [/billing@/i, /payments@/i, /accounting@/i, /invoice@/i], + keywords: ["payment", "invoice", "receipt", "statement", "bill"], + }, +]; + +export const categorizeSender = ( + email: string, + name: string, + subjectLines: string[], + contents: string[], +) => { + // 1. check if the sender matches a hard coded pattern + // 1a. check if the sender is a newsletter + // 1b. check if the sender is a receipt + // 2. if not, send the sender to the ai. do we want to do this in batches? to save on tokens? + // we will need to send email contents too + // // Check each rule + // const matchedRule = rules.find((rule) => { + // // Check email patterns + // const hasMatchingPattern = rule.patterns.some((pattern) => + // pattern.test(email.toLowerCase()), + // ); + // if (hasMatchingPattern) return true; + // // Check keywords in subject lines + // if (rule.keywords && subjectLines.length > 0) { + // const hasMatchingKeyword = subjectLines.some((subject) => + // rule.keywords!.some((keyword) => + // subject.toLowerCase().includes(keyword.toLowerCase()), + // ), + // ); + // if (hasMatchingKeyword) return true; + // } + // return false; + // }); + // if (matchedRule) { + // return matchedRule.category; + // } + // // Check for personal email indicators + // const personalIndicators = [ + // // No company domain + // !/.com|.org|.net|.edu|.gov/i.test(email), + // // Uses a personal email service + // /@gmail.|@yahoo.|@hotmail.|@outlook./i.test(email), + // // Display name looks like a person's name (basic check) + // /^[A-Z][a-z]+ [A-Z][a-z]+$/.test(name), + // ]; + // if (personalIndicators.filter(Boolean).length >= 2) { + // return SenderCategory.PERSONAL; + // } + // return SenderCategory.UNKNOWN; +}; diff --git a/apps/web/app/api/user/categorise/senders/find-senders.ts b/apps/web/app/api/user/categorise/senders/find-senders.ts index ab955ba52..1cb20985c 100644 --- a/apps/web/app/api/user/categorise/senders/find-senders.ts +++ b/apps/web/app/api/user/categorise/senders/find-senders.ts @@ -3,12 +3,13 @@ import { extractEmailAddress } from "@/utils/email"; import { getMessage } from "@/utils/gmail/message"; import { getThreadsWithNextPageToken } from "@/utils/gmail/thread"; import type { MessageWithPayload } from "@/utils/types"; +import type { SenderMap } from "@/app/api/user/categorise/senders/types"; export async function findSendersWithPagination( gmail: gmail_v1.Gmail, maxPages: number, ) { - const allSenders = new Set(); + const allSenders: SenderMap = new Map(); let nextPageToken: string | undefined = undefined; let currentPage = 0; @@ -18,7 +19,10 @@ export async function findSendersWithPagination( nextPageToken, ); - senders.forEach((sender) => allSenders.add(sender)); + senders.forEach(([sender, messages]) => { + const existingMessages = allSenders.get(sender) ?? []; + allSenders.set(sender, [...existingMessages, ...messages]); + }); if (!newNextPageToken) break; // No more pages @@ -30,7 +34,7 @@ export async function findSendersWithPagination( } async function findSenders(gmail: gmail_v1.Gmail, pageToken?: string) { - const senders = new Set(); + const senders: SenderMap = new Map(); const { threads, nextPageToken } = await getThreadsWithNextPageToken( `-in:sent`, @@ -46,7 +50,10 @@ async function findSenders(gmail: gmail_v1.Gmail, pageToken?: string) { const message = await getMessage(firstMessage.id, gmail, "metadata"); const sender = extractSenderInfo(message); - if (sender) senders.add(sender); + if (sender) { + const existingMessages = senders.get(sender) ?? []; + senders.set(sender, [...existingMessages, message]); + } } return { senders: Array.from(senders), nextPageToken }; diff --git a/apps/web/app/api/user/categorise/senders/types.ts b/apps/web/app/api/user/categorise/senders/types.ts new file mode 100644 index 000000000..c7ab58c39 --- /dev/null +++ b/apps/web/app/api/user/categorise/senders/types.ts @@ -0,0 +1,3 @@ +import { MessageWithPayload } from "@/utils/types"; + +export type SenderMap = Map; diff --git a/apps/web/utils/ai/categorise-sender/ai-categorise-senders.ts b/apps/web/utils/ai/categorise-sender/ai-categorise-senders.ts new file mode 100644 index 000000000..e83f2adfb --- /dev/null +++ b/apps/web/utils/ai/categorise-sender/ai-categorise-senders.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { SenderCategory } from "@/app/api/user/categorise/senders/categorise-sender"; +import { chatCompletionTools } from "@/utils/llms"; +import { UserAIFields } from "@/utils/llms/types"; +import type { User } from "@prisma/client"; + +const categories = [ + ...Object.values(SenderCategory).filter((c) => c !== "unknown"), + "request_more_information", +]; + +const categoriseSendersSchema = z.object({ + senders: z + .array( + z.object({ + sender: z.string().describe("The email address of the sender"), + category: z + .enum(categories as [string, ...string[]]) + .describe("The category of the sender"), + }), + ) + .describe("An array of senders and their categories"), +}); + +type CategoriseSenders = z.infer; + +export async function aiCategoriseSenders({ + user, + senders, +}: { + user: Pick & UserAIFields; + senders: string[]; +}) { + if (senders.length === 0) return []; + + const system = `You are an AI assistant specializing in email management and organization. +Your task is to categorize email senders based on their names, email addresses, and any available content patterns. +Provide accurate categorizations to help users efficiently manage their inbox.`; + + const prompt = `Categorize the following email senders: ${senders.join(", ")}. + +Instructions: +1. Analyze each sender's name and email address for clues about their category. +2. If the sender's category is clear, assign it confidently. +3. If you're unsure or if multiple categories could apply, respond with "request_more_information". +4. If requesting more information, use "request_more_information" as the value. + +Example response: +{ + "newsletter@store.com": "newsletter", + "john@company.com": "request_more_information", + "unknown@example.com": "request_more_information" +} + +Remember, it's better to request more information than to categorize incorrectly.`; + + const aiResponse = await chatCompletionTools({ + userAi: user, + system, + prompt, + tools: { + categoriseSenders: { + description: "Categorise senders", + parameters: categoriseSendersSchema, + }, + }, + userEmail: user.email || "", + label: "Categorise senders", + }); + + const result: CategoriseSenders["senders"] = aiResponse.toolCalls.find( + ({ toolName }) => toolName === "categoriseSenders", + )?.args.senders; + + return result; +} From 8b618129a3f9a4211cec1c5dce3c82ae1404c3e5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 23 Oct 2024 16:33:34 -0400 Subject: [PATCH 04/47] rename categorise to be consistently categorize --- ....test.ts => ai-categorize-senders.test.ts} | 12 ++--- .../{categorise => categorize}/controller.ts | 18 +++---- .../{categorise => categorize}/validation.ts | 8 +-- .../categorise/senders/categorise-senders.ts | 1 - .../senders/categorize-sender.ts} | 2 - .../categorize/senders/categorize-senders.ts | 1 + .../senders/find-senders.ts | 54 +++++++++++++------ .../senders/types.ts | 0 apps/web/utils/actions/categorize.ts | 47 +++++++++++++--- .../ai-categorize-senders.ts} | 25 +++++---- apps/web/utils/gmail/thread.ts | 18 ++++--- 11 files changed, 125 insertions(+), 61 deletions(-) rename apps/web/__tests__/{ai-categorise-senders.test.ts => ai-categorize-senders.test.ts} (83%) rename apps/web/app/api/ai/{categorise => categorize}/controller.ts (87%) rename apps/web/app/api/ai/{categorise => categorize}/validation.ts (59%) delete mode 100644 apps/web/app/api/user/categorise/senders/categorise-senders.ts rename apps/web/app/api/user/{categorise/senders/categorise-sender.ts => categorize/senders/categorize-sender.ts} (97%) create mode 100644 apps/web/app/api/user/categorize/senders/categorize-senders.ts rename apps/web/app/api/user/{categorise => categorize}/senders/find-senders.ts (54%) rename apps/web/app/api/user/{categorise => categorize}/senders/types.ts (100%) rename apps/web/utils/ai/{categorise-sender/ai-categorise-senders.ts => categorize-sender/ai-categorize-senders.ts} (73%) diff --git a/apps/web/__tests__/ai-categorise-senders.test.ts b/apps/web/__tests__/ai-categorize-senders.test.ts similarity index 83% rename from apps/web/__tests__/ai-categorise-senders.test.ts rename to apps/web/__tests__/ai-categorize-senders.test.ts index ac06389cf..475e94b55 100644 --- a/apps/web/__tests__/ai-categorise-senders.test.ts +++ b/apps/web/__tests__/ai-categorize-senders.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi } from "vitest"; -import { aiCategoriseSenders } from "@/utils/ai/categorise-sender/ai-categorise-senders"; -import { SenderCategory } from "@/app/api/user/categorise/senders/categorise-sender"; +import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders"; +import { SenderCategory } from "@/app/api/user/categorize/senders/categorize-sender"; vi.mock("server-only", () => ({})); -describe("aiCategoriseSenders", () => { +describe("aiCategorizeSenders", () => { const user = { email: "user@test.com", aiProvider: null, @@ -21,7 +21,7 @@ describe("aiCategoriseSenders", () => { "noreply@socialnetwork.com", ]; - const result = await aiCategoriseSenders({ user, senders }); + const result = await aiCategorizeSenders({ user, senders }); expect(result).toHaveLength(senders.length); expect(result).toEqual( @@ -54,7 +54,7 @@ describe("aiCategoriseSenders", () => { it("should handle empty senders list", async () => { const senders: string[] = []; - const result = await aiCategoriseSenders({ user, senders }); + const result = await aiCategorizeSenders({ user, senders }); expect(result).toEqual([]); }); @@ -64,7 +64,7 @@ describe("aiCategoriseSenders", () => { .filter((category) => category !== "unknown") .map((category) => `${category}@example.com`); - const result = await aiCategoriseSenders({ user, senders }); + const result = await aiCategorizeSenders({ user, senders }); expect(result).toHaveLength(senders.length); diff --git a/apps/web/app/api/ai/categorise/controller.ts b/apps/web/app/api/ai/categorize/controller.ts similarity index 87% rename from apps/web/app/api/ai/categorise/controller.ts rename to apps/web/app/api/ai/categorize/controller.ts index cc09cfed1..dd482ab92 100644 --- a/apps/web/app/api/ai/categorise/controller.ts +++ b/apps/web/app/api/ai/categorize/controller.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; import type { UserAIFields } from "@/utils/llms/types"; import { getCategory, saveCategory } from "@/utils/redis/category"; -import type { CategoriseBody } from "@/app/api/ai/categorise/validation"; +import type { CategorizeBody } from "@/app/api/ai/categorize/validation"; import { truncate } from "@/utils/string"; -export type CategoriseResponse = Awaited>; +export type CategorizeResponse = Awaited>; const aiResponseSchema = z.object({ requiresMoreInformation: z.boolean(), @@ -29,8 +29,8 @@ const aiResponseSchema = z.object({ .optional(), }); -async function aiCategorise( - body: CategoriseBody & { content: string } & UserAIFields, +async function aicategorize( + body: CategorizeBody & { content: string } & UserAIFields, expanded: boolean, userEmail: string, ) { @@ -90,8 +90,8 @@ ${expanded ? truncate(body.content, 2000) : body.snippet} return response; } -export async function categorise( - body: CategoriseBody & { content: string } & UserAIFields, +export async function categorize( + body: CategorizeBody & { content: string } & UserAIFields, options: { email: string }, ): Promise<{ category: string } | undefined> { // 1. check redis cache @@ -100,11 +100,11 @@ export async function categorise( threadId: body.threadId, }); if (existingCategory) return existingCategory; - // 2. ai categorise - let category = await aiCategorise(body, false, options.email); + // 2. ai categorize + let category = await aicategorize(body, false, options.email); if (category.object.requiresMoreInformation) { console.log("Not enough information, expanding email and trying again"); - category = await aiCategorise(body, true, options.email); + category = await aicategorize(body, true, options.email); } if (!category.object.category) return; diff --git a/apps/web/app/api/ai/categorise/validation.ts b/apps/web/app/api/ai/categorize/validation.ts similarity index 59% rename from apps/web/app/api/ai/categorise/validation.ts rename to apps/web/app/api/ai/categorize/validation.ts index ddb4e7a04..35f11f24a 100644 --- a/apps/web/app/api/ai/categorise/validation.ts +++ b/apps/web/app/api/ai/categorize/validation.ts @@ -1,21 +1,21 @@ import { z } from "zod"; -export const categoriseBody = z.object({ +export const categorizeBody = z.object({ threadId: z.string(), from: z.string(), subject: z.string(), }); -export type CategoriseBody = z.infer & { +export type CategorizeBody = z.infer & { content: string; snippet: string; unsubscribeLink?: string; hasPreviousEmail: boolean; }; -export const categoriseBodyWithHtml = categoriseBody.extend({ +export const categorizeBodyWithHtml = categorizeBody.extend({ textPlain: z.string().nullable(), textHtml: z.string().nullable(), snippet: z.string().nullable(), date: z.string(), }); -export type CategoriseBodyWithHtml = z.infer; +export type CategorizeBodyWithHtml = z.infer; diff --git a/apps/web/app/api/user/categorise/senders/categorise-senders.ts b/apps/web/app/api/user/categorise/senders/categorise-senders.ts deleted file mode 100644 index bf5a0b2c7..000000000 --- a/apps/web/app/api/user/categorise/senders/categorise-senders.ts +++ /dev/null @@ -1 +0,0 @@ -export async function categoriseSenders() {} diff --git a/apps/web/app/api/user/categorise/senders/categorise-sender.ts b/apps/web/app/api/user/categorize/senders/categorize-sender.ts similarity index 97% rename from apps/web/app/api/user/categorise/senders/categorise-sender.ts rename to apps/web/app/api/user/categorize/senders/categorize-sender.ts index ec36b45a8..4f0dd0809 100644 --- a/apps/web/app/api/user/categorise/senders/categorise-sender.ts +++ b/apps/web/app/api/user/categorize/senders/categorize-sender.ts @@ -74,8 +74,6 @@ export const categorizeSender = ( contents: string[], ) => { // 1. check if the sender matches a hard coded pattern - // 1a. check if the sender is a newsletter - // 1b. check if the sender is a receipt // 2. if not, send the sender to the ai. do we want to do this in batches? to save on tokens? // we will need to send email contents too // // Check each rule diff --git a/apps/web/app/api/user/categorize/senders/categorize-senders.ts b/apps/web/app/api/user/categorize/senders/categorize-senders.ts new file mode 100644 index 000000000..12b04a4e8 --- /dev/null +++ b/apps/web/app/api/user/categorize/senders/categorize-senders.ts @@ -0,0 +1 @@ +export async function categorizeSenders() {} diff --git a/apps/web/app/api/user/categorise/senders/find-senders.ts b/apps/web/app/api/user/categorize/senders/find-senders.ts similarity index 54% rename from apps/web/app/api/user/categorise/senders/find-senders.ts rename to apps/web/app/api/user/categorize/senders/find-senders.ts index 1cb20985c..f5741a03b 100644 --- a/apps/web/app/api/user/categorise/senders/find-senders.ts +++ b/apps/web/app/api/user/categorize/senders/find-senders.ts @@ -3,7 +3,7 @@ import { extractEmailAddress } from "@/utils/email"; import { getMessage } from "@/utils/gmail/message"; import { getThreadsWithNextPageToken } from "@/utils/gmail/thread"; import type { MessageWithPayload } from "@/utils/types"; -import type { SenderMap } from "@/app/api/user/categorise/senders/types"; +import type { SenderMap } from "@/app/api/user/categorize/senders/types"; export async function findSendersWithPagination( gmail: gmail_v1.Gmail, @@ -19,7 +19,7 @@ export async function findSendersWithPagination( nextPageToken, ); - senders.forEach(([sender, messages]) => { + Object.entries(senders).forEach(([sender, messages]) => { const existingMessages = allSenders.get(sender) ?? []; allSenders.set(sender, [...existingMessages, ...messages]); }); @@ -33,30 +33,38 @@ export async function findSendersWithPagination( return Array.from(allSenders); } -async function findSenders(gmail: gmail_v1.Gmail, pageToken?: string) { +export async function findSenders( + gmail: gmail_v1.Gmail, + pageToken?: string, + maxResults = 50, +) { const senders: SenderMap = new Map(); - const { threads, nextPageToken } = await getThreadsWithNextPageToken( - `-in:sent`, - [], + const { threads, nextPageToken } = await getThreadsWithNextPageToken({ + q: `-in:sent`, gmail, - 100, + maxResults, pageToken, - ); + }); for (const thread of threads) { - const firstMessage = thread.messages?.[0]; - if (!firstMessage?.id) continue; - const message = await getMessage(firstMessage.id, gmail, "metadata"); + if (!thread.id) continue; + try { + const message = await getMessage(thread.id, gmail, "metadata"); + console.log("🚀 ~ message:", message.id); - const sender = extractSenderInfo(message); - if (sender) { - const existingMessages = senders.get(sender) ?? []; - senders.set(sender, [...existingMessages, message]); + const sender = extractSenderInfo(message); + if (sender) { + const existingMessages = senders.get(sender) ?? []; + senders.set(sender, [...existingMessages, message]); + } + } catch (error) { + if (isNotFoundError(error)) continue; + console.error("Error getting message", error); } } - return { senders: Array.from(senders), nextPageToken }; + return { senders, nextPageToken }; } function extractSenderInfo(message: MessageWithPayload) { @@ -65,3 +73,17 @@ function extractSenderInfo(message: MessageWithPayload) { return extractEmailAddress(fromHeader.value); } + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "errors" in error && + Array.isArray((error as any).errors) && + (error as any).errors.some( + (e: any) => + e.message === "Requested entity was not found." && + e.reason === "notFound", + ) + ); +} diff --git a/apps/web/app/api/user/categorise/senders/types.ts b/apps/web/app/api/user/categorize/senders/types.ts similarity index 100% rename from apps/web/app/api/user/categorise/senders/types.ts rename to apps/web/app/api/user/categorize/senders/types.ts diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index b410984d0..30e429349 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -1,10 +1,10 @@ "use server"; -import { categorise } from "@/app/api/ai/categorise/controller"; +import { categorize } from "@/app/api/ai/categorize/controller"; import { - type CategoriseBodyWithHtml, - categoriseBodyWithHtml, -} from "@/app/api/ai/categorise/validation"; + type CategorizeBodyWithHtml, + categorizeBodyWithHtml, +} from "@/app/api/ai/categorize/validation"; import { getSessionAndGmailClient } from "@/utils/actions/helpers"; import { hasPreviousEmailsFromSender } from "@/utils/gmail/message"; import { emailToContent } from "@/utils/mail"; @@ -12,10 +12,12 @@ import { findUnsubscribeLink } from "@/utils/parse/parseHtml.server"; import { truncate } from "@/utils/string"; import prisma from "@/utils/prisma"; import { withActionInstrumentation } from "@/utils/actions/middleware"; +import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders"; +import { findSenders } from "@/app/api/user/categorize/senders/find-senders"; export const categorizeAction = withActionInstrumentation( "categorize", - async (unsafeData: CategoriseBodyWithHtml) => { + async (unsafeData: CategorizeBodyWithHtml) => { const { gmail, user: u, error } = await getSessionAndGmailClient(); if (error) return { error }; if (!gmail) return { error: "Could not load Gmail" }; @@ -24,7 +26,7 @@ export const categorizeAction = withActionInstrumentation( success, data, error: parseError, - } = categoriseBodyWithHtml.safeParse(unsafeData); + } = categorizeBodyWithHtml.safeParse(unsafeData); if (!success) return { error: parseError.message }; const content = emailToContent(data); @@ -43,7 +45,7 @@ export const categorizeAction = withActionInstrumentation( const unsubscribeLink = findUnsubscribeLink(data.textHtml); const hasPreviousEmail = await hasPreviousEmailsFromSender(gmail, data); - const res = await categorise( + const res = await categorize( { ...data, content, @@ -60,3 +62,34 @@ export const categorizeAction = withActionInstrumentation( return { category: res?.category }; }, ); + +export const categorizeSendersAction = withActionInstrumentation( + "categorizeSenders", + async () => { + const { gmail, user: u, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const user = await prisma.user.findUnique({ + where: { id: u.id }, + select: { + email: true, + aiProvider: true, + aiModel: true, + aiApiKey: true, + }, + }); + + if (!user) return { error: "User not found" }; + + const sendersResult = await findSenders(gmail); + + const senders = Array.from(sendersResult.senders.keys()); + console.log("🚀 ~ senders:", senders); + + const result = await aiCategorizeSenders({ user, senders }); + console.log("🚀 ~ result:", result); + + return result; + }, +); diff --git a/apps/web/utils/ai/categorise-sender/ai-categorise-senders.ts b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts similarity index 73% rename from apps/web/utils/ai/categorise-sender/ai-categorise-senders.ts rename to apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts index e83f2adfb..4b51cfd3b 100644 --- a/apps/web/utils/ai/categorise-sender/ai-categorise-senders.ts +++ b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { SenderCategory } from "@/app/api/user/categorise/senders/categorise-sender"; +import { SenderCategory } from "@/app/api/user/categorize/senders/categorize-sender"; import { chatCompletionTools } from "@/utils/llms"; import { UserAIFields } from "@/utils/llms/types"; import type { User } from "@prisma/client"; @@ -9,7 +9,7 @@ const categories = [ "request_more_information", ]; -const categoriseSendersSchema = z.object({ +const categorizeSendersSchema = z.object({ senders: z .array( z.object({ @@ -17,14 +17,19 @@ const categoriseSendersSchema = z.object({ category: z .enum(categories as [string, ...string[]]) .describe("The category of the sender"), + // confidence: z + // .number() + // .describe( + // "The confidence score of the category. A value between 0 and 100.", + // ), }), ) .describe("An array of senders and their categories"), }); -type CategoriseSenders = z.infer; +type CategorizeSenders = z.infer; -export async function aiCategoriseSenders({ +export async function aiCategorizeSenders({ user, senders, }: { @@ -59,17 +64,17 @@ Remember, it's better to request more information than to categorize incorrectly system, prompt, tools: { - categoriseSenders: { - description: "Categorise senders", - parameters: categoriseSendersSchema, + categorizeSenders: { + description: "categorize senders", + parameters: categorizeSendersSchema, }, }, userEmail: user.email || "", - label: "Categorise senders", + label: "categorize senders", }); - const result: CategoriseSenders["senders"] = aiResponse.toolCalls.find( - ({ toolName }) => toolName === "categoriseSenders", + const result: CategorizeSenders["senders"] = aiResponse.toolCalls.find( + ({ toolName }) => toolName === "categorizeSenders", )?.args.senders; return result; diff --git a/apps/web/utils/gmail/thread.ts b/apps/web/utils/gmail/thread.ts index cebf110ee..1c9c78228 100644 --- a/apps/web/utils/gmail/thread.ts +++ b/apps/web/utils/gmail/thread.ts @@ -21,13 +21,19 @@ export async function getThreads( return threads.data || []; } -export async function getThreadsWithNextPageToken( - q: string, - labelIds: string[], - gmail: gmail_v1.Gmail, +export async function getThreadsWithNextPageToken({ + gmail, + q, + labelIds, maxResults = 100, - pageToken?: string, -) { + pageToken, +}: { + gmail: gmail_v1.Gmail; + q: string; + labelIds?: string[]; + maxResults?: number; + pageToken?: string; +}) { const threads = await gmail.users.threads.list({ userId: "me", q, From 46d2d163fa2390a85492cb3e208468fa8b08c2ba Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 24 Oct 2024 22:52:01 -0400 Subject: [PATCH 05/47] basic categorisation of senders and display in ui --- apps/web/.env.example | 3 +- apps/web/app/(app)/categories/page.tsx | 44 +++ .../categorize/senders/categorize-sender.ts | 136 ++++--- .../user/categorize/senders/find-senders.ts | 5 +- apps/web/components/kanban/BoardColumn.tsx | 128 ++++++ apps/web/components/kanban/KanbanBoard.tsx | 365 ++++++++++++++++++ apps/web/components/kanban/TaskCard.tsx | 82 ++++ apps/web/components/kanban/kanban-utils.ts | 23 ++ .../multipleContainersKeyboardPreset.ts | 109 ++++++ apps/web/env.ts | 1 - apps/web/package.json | 3 + .../20241023204900_category/migration.sql | 23 ++ apps/web/prisma/schema.prisma | 20 +- apps/web/utils/actions/categorize.ts | 109 +++++- .../ai-categorize-senders.ts | 34 +- apps/web/utils/ai/group/find-newsletters.ts | 6 +- apps/web/utils/ai/group/find-receipts.ts | 6 +- pnpm-lock.yaml | 48 ++- turbo.json | 1 - 19 files changed, 1049 insertions(+), 97 deletions(-) create mode 100644 apps/web/app/(app)/categories/page.tsx create mode 100644 apps/web/components/kanban/BoardColumn.tsx create mode 100644 apps/web/components/kanban/KanbanBoard.tsx create mode 100644 apps/web/components/kanban/TaskCard.tsx create mode 100644 apps/web/components/kanban/kanban-utils.ts create mode 100644 apps/web/components/kanban/multipleContainersKeyboardPreset.ts create mode 100644 apps/web/prisma/migrations/20241023204900_category/migration.sql diff --git a/apps/web/.env.example b/apps/web/.env.example index b4ee9a7cb..1a054c7c8 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,7 +1,6 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/inboxzero?schema=public" DIRECT_URL="postgresql://postgres:password@localhost:5432/inboxzero?schema=public" -NEXTAUTH_URL=http://localhost:3000 # Generate a random secret here: https://generate-secret.vercel.app/32 NEXTAUTH_SECRET= @@ -12,7 +11,7 @@ OPENAI_API_KEY= BEDROCK_ACCESS_KEY= BEDROCK_SECRET_KEY= -BEDROCK_REGION=us-east-1 +BEDROCK_REGION=us-west-2 #redis config UPSTASH_REDIS_URL="http://localhost:8079" diff --git a/apps/web/app/(app)/categories/page.tsx b/apps/web/app/(app)/categories/page.tsx new file mode 100644 index 000000000..07654293d --- /dev/null +++ b/apps/web/app/(app)/categories/page.tsx @@ -0,0 +1,44 @@ +import { capitalCase } from "capital-case"; +import { KanbanBoard } from "@/components/kanban/KanbanBoard"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { ClientOnly } from "@/components/ClientOnly"; + +export const dynamic = "force-dynamic"; + +export default async function CategoriesPage() { + const session = await auth(); + const email = session?.user.email; + if (!email) throw new Error("Not authenticated"); + + const [categories, senders] = await Promise.all([ + prisma.category.findMany({ + where: { OR: [{ userId: session.user.id }, { userId: null }] }, + select: { id: true, name: true }, + }), + prisma.newsletter.findMany({ + where: { userId: session.user.id, categoryId: { not: null } }, + select: { id: true, email: true, categoryId: true }, + }), + ]); + + if (!categories.length) return
No categories found
; + + return ( +
+ + ({ + id: c.id, + title: capitalCase(c.name), + }))} + items={senders.map((s) => ({ + id: s.id, + columnId: s.categoryId || "Uncategorized", + content: s.email, + }))} + /> + +
+ ); +} diff --git a/apps/web/app/api/user/categorize/senders/categorize-sender.ts b/apps/web/app/api/user/categorize/senders/categorize-sender.ts index 4f0dd0809..096333566 100644 --- a/apps/web/app/api/user/categorize/senders/categorize-sender.ts +++ b/apps/web/app/api/user/categorize/senders/categorize-sender.ts @@ -1,24 +1,99 @@ export const SenderCategory = { UNKNOWN: "unknown", + // Emails that don't fit any other category or can't be classified + NEWSLETTER: "newsletter", + // "Weekly Tech Digest from TechCrunch" + // "Monthly Fitness Tips from GymPro" + // "Daily News Roundup from The New York Times" + MARKETING: "marketing", + // "50% Off Summer Sale at Fashion Store" + // "New Product Launch: Try Our Latest Gadget" + // "Limited Time Offer: Join Our Premium Membership" + RECEIPT: "receipt", - FINANCE: "finance", + // "Your Amazon.com order confirmation" + // "Receipt for your recent purchase at Apple Store" + // "Payment confirmation for your Netflix subscription" + + BANKING: "banking", + // "Your monthly statement from Chase Bank" + // "Important update about your savings account" + // "Fraud alert: Unusual activity detected on your card" + LEGAL: "legal", + // "Updates to our Terms of Service" + // "Important information about your lawsuit" + // "Contract for your review and signature" + SUPPORT: "support", + // "Your support ticket #12345 has been resolved" + // "Follow-up on your recent customer service inquiry" + // "Troubleshooting guide for your recent issue" + PERSONAL: "personal", + // "Hey, want to grab coffee this weekend?" + // "Photos from last night's dinner" + // "Happy birthday wishes from Mom" + WORK: "work", + // "Meeting agenda for tomorrow's team sync" + // "Quarterly performance review reminder" + // "New project kickoff: Action items" + SOCIAL: "social", - TRANSACTIONAL: "transactional", + // "John Smith tagged you in a photo on Facebook" + // "New connection request on LinkedIn" + // "Your friend's status update on Instagram" + EDUCATIONAL: "educational", + // "Your course schedule for the upcoming semester" + // "Reminder: Assignment due next week" + // "New learning resources available in your online class" + TRAVEL: "travel", + // "Your flight itinerary for upcoming trip" + // "Hotel reservation confirmation" + // "Travel insurance policy for your vacation" + HEALTH: "health", + // "Reminder: Your dental appointment is tomorrow" + // "Lab test results are now available" + // "Your prescription is ready for pickup" + GOVERNMENT: "government", + // "Important update about your tax return" + // "Voter registration information" + // "Census Bureau: Please complete your survey" + CHARITY: "charity", + // "Thank you for your recent donation" + // "Volunteer opportunity: Help at our upcoming event" + // "Impact report: See how your contributions helped" + ENTERTAINMENT: "entertainment", + // "New movies available on your streaming service" + // "Exclusive preview of our upcoming video game release" + + EVENTS: "events", + // "Invitation to Sarah's wedding" + // "Reminder: Community BBQ this Saturday" + // "Conference schedule and registration information" + + SHOPPING: "shopping", + // "Your wishlist items are now on sale" + // "New arrivals at your favorite store" + // "Shipping update: Your package is on its way" + + ACCOUNT: "account", + // "Password reset for your account" + // "Security alert: New login detected" + // "Your account settings have been updated" } as const; -type SenderCategory = (typeof SenderCategory)[keyof typeof SenderCategory]; +export type SenderCategory = + (typeof SenderCategory)[keyof typeof SenderCategory]; interface CategoryRule { category: SenderCategory; @@ -50,64 +125,9 @@ const rules: CategoryRule[] = [ patterns: [/marketing@/i, /promotions@/i, /offers@/i, /sales@/i], keywords: ["offer", "discount", "sale", "limited time", "exclusive"], }, - // { - // category: SenderCategory.CUSTOMER_SERVICE, - // patterns: [/support@/i, /help@/i, /customerservice@/i, /care@/i], - // keywords: ["ticket", "case", "inquiry", "support"], - // }, { category: SenderCategory.LEGAL, patterns: [/legal@/i, /compliance@/i, /notices@/i], keywords: ["agreement", "terms", "policy", "compliance"], }, - { - category: SenderCategory.FINANCE, - patterns: [/billing@/i, /payments@/i, /accounting@/i, /invoice@/i], - keywords: ["payment", "invoice", "receipt", "statement", "bill"], - }, ]; - -export const categorizeSender = ( - email: string, - name: string, - subjectLines: string[], - contents: string[], -) => { - // 1. check if the sender matches a hard coded pattern - // 2. if not, send the sender to the ai. do we want to do this in batches? to save on tokens? - // we will need to send email contents too - // // Check each rule - // const matchedRule = rules.find((rule) => { - // // Check email patterns - // const hasMatchingPattern = rule.patterns.some((pattern) => - // pattern.test(email.toLowerCase()), - // ); - // if (hasMatchingPattern) return true; - // // Check keywords in subject lines - // if (rule.keywords && subjectLines.length > 0) { - // const hasMatchingKeyword = subjectLines.some((subject) => - // rule.keywords!.some((keyword) => - // subject.toLowerCase().includes(keyword.toLowerCase()), - // ), - // ); - // if (hasMatchingKeyword) return true; - // } - // return false; - // }); - // if (matchedRule) { - // return matchedRule.category; - // } - // // Check for personal email indicators - // const personalIndicators = [ - // // No company domain - // !/.com|.org|.net|.edu|.gov/i.test(email), - // // Uses a personal email service - // /@gmail.|@yahoo.|@hotmail.|@outlook./i.test(email), - // // Display name looks like a person's name (basic check) - // /^[A-Z][a-z]+ [A-Z][a-z]+$/.test(name), - // ]; - // if (personalIndicators.filter(Boolean).length >= 2) { - // return SenderCategory.PERSONAL; - // } - // return SenderCategory.UNKNOWN; -}; diff --git a/apps/web/app/api/user/categorize/senders/find-senders.ts b/apps/web/app/api/user/categorize/senders/find-senders.ts index f5741a03b..781d9cf67 100644 --- a/apps/web/app/api/user/categorize/senders/find-senders.ts +++ b/apps/web/app/api/user/categorize/senders/find-senders.ts @@ -30,7 +30,7 @@ export async function findSendersWithPagination( currentPage++; } - return Array.from(allSenders); + return { senders: allSenders, nextPageToken }; } export async function findSenders( @@ -51,7 +51,6 @@ export async function findSenders( if (!thread.id) continue; try { const message = await getMessage(thread.id, gmail, "metadata"); - console.log("🚀 ~ message:", message.id); const sender = extractSenderInfo(message); if (sender) { @@ -71,7 +70,7 @@ function extractSenderInfo(message: MessageWithPayload) { const fromHeader = message.payload?.headers?.find((h) => h.name === "From"); if (!fromHeader?.value) return null; - return extractEmailAddress(fromHeader.value); + return fromHeader.value; } function isNotFoundError(error: unknown): boolean { diff --git a/apps/web/components/kanban/BoardColumn.tsx b/apps/web/components/kanban/BoardColumn.tsx new file mode 100644 index 000000000..badf67ec9 --- /dev/null +++ b/apps/web/components/kanban/BoardColumn.tsx @@ -0,0 +1,128 @@ +import { useMemo } from "react"; +import { SortableContext, useSortable } from "@dnd-kit/sortable"; +import { useDndContext, type UniqueIdentifier } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import { cva } from "class-variance-authority"; +import { GripVertical } from "lucide-react"; +import { Task, TaskCard } from "./TaskCard"; +import { Card, CardHeader, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ScrollBar, ScrollArea } from "@/components/ui/scroll-area"; + +export interface Column { + id: UniqueIdentifier; + title: string; +} + +export type ColumnType = "Column"; + +export interface ColumnDragData { + type: ColumnType; + column: Column; +} + +interface BoardColumnProps { + column: Column; + tasks: Task[]; + isOverlay?: boolean; +} + +export function BoardColumn({ column, tasks, isOverlay }: BoardColumnProps) { + const tasksIds = useMemo(() => { + return tasks.map((task) => task.id); + }, [tasks]); + + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ + id: column.id, + data: { + type: "Column", + column, + } satisfies ColumnDragData, + attributes: { + roleDescription: `Column: ${column.title}`, + }, + }); + + const style = { + transition, + transform: CSS.Translate.toString(transform), + }; + + const variants = cva( + "h-[800px] max-h-[800px] w-[350px] max-w-full bg-primary-foreground flex flex-col flex-shrink-0 snap-center", + { + variants: { + dragging: { + default: "border-2 border-transparent", + over: "ring-2 opacity-30", + overlay: "ring-2 ring-primary", + }, + }, + }, + ); + + return ( + + + + {column.title} + + + + + {tasks.map((task) => ( + + ))} + + + + + ); +} + +export function BoardContainer({ children }: { children: React.ReactNode }) { + const dndContext = useDndContext(); + + const variations = cva("px-2 md:px-0 flex lg:justify-center pb-4", { + variants: { + dragging: { + default: "snap-x snap-mandatory", + active: "snap-none", + }, + }, + }); + + return ( + +
+ {children} +
+ +
+ ); +} diff --git a/apps/web/components/kanban/KanbanBoard.tsx b/apps/web/components/kanban/KanbanBoard.tsx new file mode 100644 index 000000000..112e47020 --- /dev/null +++ b/apps/web/components/kanban/KanbanBoard.tsx @@ -0,0 +1,365 @@ +"use client"; + +// based off of https://github.com/Georgegriff/react-dnd-kit-tailwind-shadcn-ui + +import { useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { BoardColumn, BoardContainer } from "./BoardColumn"; +import { + DndContext, + type DragEndEvent, + type DragOverEvent, + DragOverlay, + type DragStartEvent, + useSensor, + useSensors, + KeyboardSensor, + Announcements, + UniqueIdentifier, + TouchSensor, + MouseSensor, +} from "@dnd-kit/core"; +import { SortableContext, arrayMove } from "@dnd-kit/sortable"; +import { type Task, TaskCard } from "./TaskCard"; +import type { Column } from "./BoardColumn"; +import { hasDraggableData } from "./kanban-utils"; +import { coordinateGetter } from "./multipleContainersKeyboardPreset"; + +// const initialTasks: Task[] = [ +// { +// id: "task1", +// columnId: "done", +// content: "Project initiation and planning", +// }, +// { +// id: "task2", +// columnId: "done", +// content: "Gather requirements from stakeholders", +// }, +// { +// id: "task3", +// columnId: "done", +// content: "Create wireframes and mockups", +// }, +// { +// id: "task4", +// columnId: "in-progress", +// content: "Develop homepage layout", +// }, +// { +// id: "task5", +// columnId: "in-progress", +// content: "Design color scheme and typography", +// }, +// { +// id: "task6", +// columnId: "todo", +// content: "Implement user authentication", +// }, +// { +// id: "task7", +// columnId: "todo", +// content: "Build contact us page", +// }, +// { +// id: "task8", +// columnId: "todo", +// content: "Create product catalog", +// }, +// { +// id: "task9", +// columnId: "todo", +// content: "Develop about us page", +// }, +// { +// id: "task10", +// columnId: "todo", +// content: "Optimize website for mobile devices", +// }, +// { +// id: "task11", +// columnId: "todo", +// content: "Integrate payment gateway", +// }, +// { +// id: "task12", +// columnId: "todo", +// content: "Perform testing and bug fixing", +// }, +// { +// id: "task13", +// columnId: "todo", +// content: "Launch website and deploy to server", +// }, +// ]; + +export function KanbanBoard({ + categories, + items, +}: { + categories: Column[]; + items: Task[]; +}) { + const [columns, setColumns] = useState(categories); + const pickedUpTaskColumn = useRef(null); + const columnsId = useMemo(() => columns.map((col) => col.id), [columns]); + + const [tasks, setTasks] = useState(items); + + const [activeColumn, setActiveColumn] = useState(null); + + const [activeTask, setActiveTask] = useState(null); + + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor, { + coordinateGetter: coordinateGetter, + }), + ); + + function getDraggingTaskData(taskId: UniqueIdentifier, columnId: string) { + const tasksInColumn = tasks.filter((task) => task.columnId === columnId); + const taskPosition = tasksInColumn.findIndex((task) => task.id === taskId); + const column = columns.find((col) => col.id === columnId); + return { + tasksInColumn, + taskPosition, + column, + }; + } + + const announcements: Announcements = { + onDragStart({ active }) { + if (!hasDraggableData(active)) return; + if (active.data.current?.type === "Column") { + const startColumnIdx = columnsId.findIndex((id) => id === active.id); + const startColumn = columns[startColumnIdx]; + return `Picked up Column ${startColumn?.title} at position: ${ + startColumnIdx + 1 + } of ${columnsId.length}`; + } else if (active.data.current?.type === "Task") { + pickedUpTaskColumn.current = active.data.current.task.columnId; + const { tasksInColumn, taskPosition, column } = getDraggingTaskData( + active.id, + pickedUpTaskColumn.current!, // TODO: ! + ); + return `Picked up Task ${ + active.data.current.task.content + } at position: ${taskPosition + 1} of ${ + tasksInColumn.length + } in column ${column?.title}`; + } + }, + onDragOver({ active, over }) { + if (!hasDraggableData(active) || !hasDraggableData(over)) return; + + if ( + active.data.current?.type === "Column" && + over.data.current?.type === "Column" + ) { + const overColumnIdx = columnsId.findIndex((id) => id === over.id); + return `Column ${active.data.current.column.title} was moved over ${ + over.data.current.column.title + } at position ${overColumnIdx + 1} of ${columnsId.length}`; + } else if ( + active.data.current?.type === "Task" && + over.data.current?.type === "Task" + ) { + const { tasksInColumn, taskPosition, column } = getDraggingTaskData( + over.id, + over.data.current.task.columnId, + ); + if (over.data.current.task.columnId !== pickedUpTaskColumn.current) { + return `Task ${ + active.data.current.task.content + } was moved over column ${column?.title} in position ${ + taskPosition + 1 + } of ${tasksInColumn.length}`; + } + return `Task was moved over position ${taskPosition + 1} of ${ + tasksInColumn.length + } in column ${column?.title}`; + } + }, + onDragEnd({ active, over }) { + if (!hasDraggableData(active) || !hasDraggableData(over)) { + pickedUpTaskColumn.current = null; + return; + } + if ( + active.data.current?.type === "Column" && + over.data.current?.type === "Column" + ) { + const overColumnPosition = columnsId.findIndex((id) => id === over.id); + + return `Column ${ + active.data.current.column.title + } was dropped into position ${overColumnPosition + 1} of ${ + columnsId.length + }`; + } else if ( + active.data.current?.type === "Task" && + over.data.current?.type === "Task" + ) { + const { tasksInColumn, taskPosition, column } = getDraggingTaskData( + over.id, + over.data.current.task.columnId, + ); + if (over.data.current.task.columnId !== pickedUpTaskColumn.current) { + return `Task was dropped into column ${column?.title} in position ${ + taskPosition + 1 + } of ${tasksInColumn.length}`; + } + return `Task was dropped into position ${taskPosition + 1} of ${ + tasksInColumn.length + } in column ${column?.title}`; + } + pickedUpTaskColumn.current = null; + }, + onDragCancel({ active }) { + pickedUpTaskColumn.current = null; + if (!hasDraggableData(active)) return; + return `Dragging ${active.data.current?.type} cancelled.`; + }, + }; + + return ( + + + + {columns.map((col) => ( + task.columnId === col.id)} + /> + ))} + + + + {"document" in window && + createPortal( + + {activeColumn && ( + task.columnId === activeColumn.id, + )} + /> + )} + {activeTask && } + , + document.body, + )} + + ); + + function onDragStart(event: DragStartEvent) { + if (!hasDraggableData(event.active)) return; + const data = event.active.data.current; + if (data?.type === "Column") { + setActiveColumn(data.column); + return; + } + + if (data?.type === "Task") { + setActiveTask(data.task); + return; + } + } + + function onDragEnd(event: DragEndEvent) { + setActiveColumn(null); + setActiveTask(null); + + const { active, over } = event; + if (!over) return; + + const activeId = active.id; + const overId = over.id; + + if (!hasDraggableData(active)) return; + + const activeData = active.data.current; + + if (activeId === overId) return; + + const isActiveAColumn = activeData?.type === "Column"; + if (!isActiveAColumn) return; + + setColumns((columns) => { + const activeColumnIndex = columns.findIndex((col) => col.id === activeId); + + const overColumnIndex = columns.findIndex((col) => col.id === overId); + + return arrayMove(columns, activeColumnIndex, overColumnIndex); + }); + } + + function onDragOver(event: DragOverEvent) { + const { active, over } = event; + if (!over) return; + + const activeId = active.id; + const overId = over.id; + + if (activeId === overId) return; + + if (!hasDraggableData(active) || !hasDraggableData(over)) return; + + const activeData = active.data.current; + const overData = over.data.current; + + const isActiveATask = activeData?.type === "Task"; + const isOverATask = overData?.type === "Task"; + + if (!isActiveATask) return; + + // Im dropping a Task over another Task + if (isActiveATask && isOverATask) { + setTasks((tasks) => { + const activeIndex = tasks.findIndex((t) => t.id === activeId); + const overIndex = tasks.findIndex((t) => t.id === overId); + const activeTask = tasks[activeIndex]; + const overTask = tasks[overIndex]; + if ( + activeTask && + overTask && + activeTask.columnId !== overTask.columnId + ) { + activeTask.columnId = overTask.columnId; + return arrayMove(tasks, activeIndex, overIndex - 1); + } + + return arrayMove(tasks, activeIndex, overIndex); + }); + } + + const isOverAColumn = overData?.type === "Column"; + + // Im dropping a Task over a column + if (isActiveATask && isOverAColumn) { + setTasks((tasks) => { + const activeIndex = tasks.findIndex((t) => t.id === activeId); + const activeTask = tasks[activeIndex]; + if (activeTask) { + activeTask.columnId = overId as string; + return arrayMove(tasks, activeIndex, activeIndex); + } + return tasks; + }); + } + } +} diff --git a/apps/web/components/kanban/TaskCard.tsx b/apps/web/components/kanban/TaskCard.tsx new file mode 100644 index 000000000..843ebe040 --- /dev/null +++ b/apps/web/components/kanban/TaskCard.tsx @@ -0,0 +1,82 @@ +import type { UniqueIdentifier } from "@dnd-kit/core"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cva } from "class-variance-authority"; +import { GripVertical } from "lucide-react"; +import { Card, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export interface Task { + id: UniqueIdentifier; + columnId: string; + content: string; +} + +interface TaskCardProps { + task: Task; + isOverlay?: boolean; +} + +export type TaskType = "Task"; + +export interface TaskDragData { + type: TaskType; + task: Task; +} + +export function TaskCard({ task, isOverlay }: TaskCardProps) { + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ + id: task.id, + data: { + type: "Task", + task, + } satisfies TaskDragData, + attributes: { + roleDescription: "Task", + }, + }); + + const style = { + transition, + transform: CSS.Translate.toString(transform), + }; + + const variants = cva("", { + variants: { + dragging: { + over: "ring-2 opacity-30", + overlay: "ring-2 ring-primary", + }, + }, + }); + + return ( + + +

{task.content}

+ +
+
+ ); +} diff --git a/apps/web/components/kanban/kanban-utils.ts b/apps/web/components/kanban/kanban-utils.ts new file mode 100644 index 000000000..71ff33da0 --- /dev/null +++ b/apps/web/components/kanban/kanban-utils.ts @@ -0,0 +1,23 @@ +import { Active, DataRef, Over } from "@dnd-kit/core"; +import { ColumnDragData } from "./BoardColumn"; +import { TaskDragData } from "./TaskCard"; + +type DraggableData = ColumnDragData | TaskDragData; + +export function hasDraggableData( + entry: T | null | undefined, +): entry is T & { + data: DataRef; +} { + if (!entry) { + return false; + } + + const data = entry.data.current; + + if (data?.type === "Column" || data?.type === "Task") { + return true; + } + + return false; +} diff --git a/apps/web/components/kanban/multipleContainersKeyboardPreset.ts b/apps/web/components/kanban/multipleContainersKeyboardPreset.ts new file mode 100644 index 000000000..5bf92c2e5 --- /dev/null +++ b/apps/web/components/kanban/multipleContainersKeyboardPreset.ts @@ -0,0 +1,109 @@ +import { + closestCorners, + getFirstCollision, + KeyboardCode, + DroppableContainer, + KeyboardCoordinateGetter, +} from "@dnd-kit/core"; + +const directions: string[] = [ + KeyboardCode.Down, + KeyboardCode.Right, + KeyboardCode.Up, + KeyboardCode.Left, +]; + +export const coordinateGetter: KeyboardCoordinateGetter = ( + event, + { context: { active, droppableRects, droppableContainers, collisionRect } }, +) => { + if (directions.includes(event.code)) { + event.preventDefault(); + + if (!active || !collisionRect) { + return; + } + + const filteredContainers: DroppableContainer[] = []; + + droppableContainers.getEnabled().forEach((entry) => { + if (!entry || entry?.disabled) { + return; + } + + const rect = droppableRects.get(entry.id); + + if (!rect) { + return; + } + + const data = entry.data.current; + + if (data) { + const { type, children } = data; + + if (type === "Column" && children?.length > 0) { + if (active.data.current?.type !== "Column") { + return; + } + } + } + + switch (event.code) { + case KeyboardCode.Down: + if (active.data.current?.type === "Column") { + return; + } + if (collisionRect.top < rect.top) { + // find all droppable areas below + filteredContainers.push(entry); + } + break; + case KeyboardCode.Up: + if (active.data.current?.type === "Column") { + return; + } + if (collisionRect.top > rect.top) { + // find all droppable areas above + filteredContainers.push(entry); + } + break; + case KeyboardCode.Left: + if (collisionRect.left >= rect.left + rect.width) { + // find all droppable areas to left + filteredContainers.push(entry); + } + break; + case KeyboardCode.Right: + // find all droppable areas to right + if (collisionRect.left + collisionRect.width <= rect.left) { + filteredContainers.push(entry); + } + break; + } + }); + const collisions = closestCorners({ + active, + collisionRect: collisionRect, + droppableRects, + droppableContainers: filteredContainers, + pointerCoordinates: null, + }); + const closestId = getFirstCollision(collisions, "id"); + + if (closestId != null) { + const newDroppable = droppableContainers.get(closestId); + const newNode = newDroppable?.node.current; + const newRect = newDroppable?.rect.current; + + if (newNode && newRect) { + return { + x: newRect.left, + y: newRect.top, + }; + } + } + } + + return undefined; +}; diff --git a/apps/web/env.ts b/apps/web/env.ts index 26b32e21d..ec9d4f194 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -6,7 +6,6 @@ export const env = createEnv({ server: { NODE_ENV: z.enum(["development", "production", "test"]), DATABASE_URL: z.string().url(), - NEXTAUTH_URL: z.string().min(1), NEXTAUTH_SECRET: z.string().min(1), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), diff --git a/apps/web/package.json b/apps/web/package.json index ac0a317c9..bb2e99aa1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,9 @@ "@asteasolutions/zod-to-openapi": "^7.2.0", "@auth/core": "^0.37.2", "@auth/prisma-adapter": "^2.7.2", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@formkit/auto-animate": "^0.8.2", "@googleapis/gmail": "^12.0.0", "@googleapis/people": "^3.0.9", diff --git a/apps/web/prisma/migrations/20241023204900_category/migration.sql b/apps/web/prisma/migrations/20241023204900_category/migration.sql new file mode 100644 index 000000000..cc3c50b01 --- /dev/null +++ b/apps/web/prisma/migrations/20241023204900_category/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable +ALTER TABLE "Newsletter" ADD COLUMN "categoryId" TEXT; + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "userId" TEXT, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Category_name_userId_key" ON "Category"("name", "userId"); + +-- AddForeignKey +ALTER TABLE "Category" ADD CONSTRAINT "Category_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Newsletter" ADD CONSTRAINT "Newsletter_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 2267250f1..d50f12587 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -90,6 +90,7 @@ model User { coldEmails ColdEmail[] groups Group[] apiKeys ApiKey[] + categories Category[] } model Premium { @@ -283,6 +284,19 @@ model GroupItem { @@unique([groupId, type, value]) } +model Category { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + description String? + userId String? // set to null for default categories + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + emailSenders Newsletter[] + + @@unique([name, userId]) +} + // Represents a sender (`email`) that a user can unsubscribe from, // or that our AI can mark as a cold email. // `Newsletter` is a bad name for this. Will rename this model in the future. @@ -293,8 +307,10 @@ model Newsletter { email String status NewsletterStatus? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + categoryId String? + category Category? @relation(fields: [categoryId], references: [id]) @@unique([email, userId]) @@index([userId, status]) diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 30e429349..f7a7e3291 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -1,5 +1,6 @@ "use server"; +import uniq from "lodash/uniq"; import { categorize } from "@/app/api/ai/categorize/controller"; import { type CategorizeBodyWithHtml, @@ -14,6 +15,9 @@ import prisma from "@/utils/prisma"; import { withActionInstrumentation } from "@/utils/actions/middleware"; import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders"; import { findSenders } from "@/app/api/user/categorize/senders/find-senders"; +import { SenderCategory } from "@/app/api/user/categorize/senders/categorize-sender"; +import { defaultReceiptSenders } from "@/utils/ai/group/find-receipts"; +import { newsletterSenders } from "@/utils/ai/group/find-newsletters"; export const categorizeAction = withActionInstrumentation( "categorize", @@ -82,14 +86,107 @@ export const categorizeSendersAction = withActionInstrumentation( if (!user) return { error: "User not found" }; - const sendersResult = await findSenders(gmail); + // TODO: fetch from gmail, run ai, then fetch from gmail,... + // we can run ai and gmail fetch in parallel - const senders = Array.from(sendersResult.senders.keys()); - console.log("🚀 ~ senders:", senders); + const sendersResult = await findSenders(gmail, undefined, 100); + // const sendersResult = await findSendersWithPagination(gmail, 5); - const result = await aiCategorizeSenders({ user, senders }); - console.log("🚀 ~ result:", result); + const senders = uniq(Array.from(sendersResult.senders.keys())); - return result; + // remove senders we've already categorized + const existingSenders = await prisma.newsletter.findMany({ + where: { email: { in: senders }, userId: u.id }, + select: { email: true }, + }); + + const sendersToCategorize = senders.filter( + (sender) => !existingSenders.some((s) => s.email === sender), + ); + + const categorizedSenders = + preCategorizeSendersWithStaticRules(sendersToCategorize); + + const sendersToCategorizeWithAi = categorizedSenders + .filter((sender) => !sender.category) + .map((sender) => sender.sender); + + const aiResults = await aiCategorizeSenders({ + user, + senders: sendersToCategorizeWithAi, + }); + + // get user categories + const categories = await prisma.category.findMany({ + where: { OR: [{ userId: u.id }, { userId: null }] }, + select: { id: true, name: true }, + }); + + const results = [...categorizedSenders, ...aiResults]; + + for (const result of results) { + if (!result.category) continue; + let category = categories.find((c) => c.name === result.category); + + if (!category) { + // create category + const newCategory = await prisma.category.create({ + data: { name: result.category, userId: u.id }, + }); + category = newCategory; + categories.push(category); + } + + // save category + await prisma.newsletter.upsert({ + where: { email_userId: { email: result.sender, userId: u.id } }, + update: { categoryId: category.id }, + create: { + email: result.sender, + userId: u.id, + categoryId: category.id, + }, + }); + } + + return results; }, ); + +// Use static rules to categorize senders if we can, before sending to LLM +function preCategorizeSendersWithStaticRules( + senders: string[], +): { sender: string; category: SenderCategory | undefined }[] { + return senders.map((sender) => { + // if the sender is @gmail.com, @yahoo.com, etc. + // then mark as "Unknown" (LLM will categorize these as "Personal") + const personalEmailDomains = [ + "gmail.com", + "googlemail.com", + "yahoo.com", + "hotmail.com", + "outlook.com", + "aol.com", + ]; + + if (personalEmailDomains.some((domain) => sender.includes(`@${domain}>`))) + return { sender, category: SenderCategory.UNKNOWN }; + + // newsletters + if ( + sender.toLowerCase().includes("newsletter") || + newsletterSenders.some((newsletter) => sender.includes(newsletter)) + ) + return { sender, category: SenderCategory.NEWSLETTER }; + + // support + if (sender.toLowerCase().includes("support")) + return { sender, category: SenderCategory.SUPPORT }; + + // receipts + if (defaultReceiptSenders.some((receipt) => sender.includes(receipt))) + return { sender, category: SenderCategory.RECEIPT }; + + return { sender, category: undefined }; + }); +} 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 4b51cfd3b..35062b65a 100644 --- a/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts +++ b/apps/web/utils/ai/categorize-sender/ai-categorize-senders.ts @@ -3,6 +3,7 @@ import { SenderCategory } from "@/app/api/user/categorize/senders/categorize-sen import { chatCompletionTools } from "@/utils/llms"; import { UserAIFields } from "@/utils/llms/types"; import type { User } from "@prisma/client"; +import { isDefined } from "@/utils/types"; const categories = [ ...Object.values(SenderCategory).filter((c) => c !== "unknown"), @@ -42,20 +43,18 @@ export async function aiCategorizeSenders({ Your task is to categorize email senders based on their names, email addresses, and any available content patterns. Provide accurate categorizations to help users efficiently manage their inbox.`; - const prompt = `Categorize the following email senders: ${senders.join(", ")}. + const prompt = `Categorize the following email senders: + + +${senders.map((sender) => `* ${sender}`).join("\n")} + Instructions: 1. Analyze each sender's name and email address for clues about their category. 2. If the sender's category is clear, assign it confidently. 3. If you're unsure or if multiple categories could apply, respond with "request_more_information". 4. If requesting more information, use "request_more_information" as the value. - -Example response: -{ - "newsletter@store.com": "newsletter", - "john@company.com": "request_more_information", - "unknown@example.com": "request_more_information" -} +5. For individual senders, you'll want to "request_more_information". For example, rachel.smith@company.com, we don't know if their a customer, or sending us marketing, or something else. Remember, it's better to request more information than to categorize incorrectly.`; @@ -77,5 +76,22 @@ Remember, it's better to request more information than to categorize incorrectly ({ toolName }) => toolName === "categorizeSenders", )?.args.senders; - return result; + // match up emails with full email + // this is done so that the LLM can return less text in the response + // and also so that we can match sure the senders it's returning are part of the input (and it didn't hallucinate) + // NOTE: if there are two senders with the same email address (but different names), it will only return one of them + const sendersWithFullEmail = result + .map((r) => { + const sender = senders.find((s) => s.includes(r.sender)); + + if (!sender) return undefined; + + return { + ...r, + sender, + }; + }) + .filter(isDefined); + + return sendersWithFullEmail; } diff --git a/apps/web/utils/ai/group/find-newsletters.ts b/apps/web/utils/ai/group/find-newsletters.ts index a38c11e70..3cf18f24c 100644 --- a/apps/web/utils/ai/group/find-newsletters.ts +++ b/apps/web/utils/ai/group/find-newsletters.ts @@ -2,7 +2,11 @@ import type { gmail_v1 } from "@googleapis/gmail"; import uniq from "lodash/uniq"; import { queryBatchMessagesPages } from "@/utils/gmail/message"; -const newsletterSenders = ["@substack.com", "@mail.beehiiv.com", "@ghost.io"]; +export const newsletterSenders = [ + "@substack.com", + "@mail.beehiiv.com", + "@ghost.io", +]; const ignoreList = ["@github.com", "@google.com", "@gmail.com", "@slack.com"]; export async function findNewsletters( diff --git a/apps/web/utils/ai/group/find-receipts.ts b/apps/web/utils/ai/group/find-receipts.ts index 3f990721a..679c43118 100644 --- a/apps/web/utils/ai/group/find-receipts.ts +++ b/apps/web/utils/ai/group/find-receipts.ts @@ -7,7 +7,11 @@ import { findMatchingGroupItem } from "@/utils/group/find-matching-group"; import { generalizeSubject } from "@/utils/string"; // Predefined lists of receipt senders and subjects -const defaultReceiptSenders = ["invoice+statements", "receipt@", "invoice@"]; +export const defaultReceiptSenders = [ + "invoice+statements", + "receipt@", + "invoice@", +]; const defaultReceiptSubjects = [ "Invoice #", "Payment Receipt", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9149d258c..b25d4bddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,15 @@ importers: '@auth/prisma-adapter': specifier: ^2.7.2 version: 2.7.2(@prisma/client@5.21.1(prisma@5.21.1))(nodemailer@6.9.15) + '@dnd-kit/core': + specifier: ^6.1.0 + version: 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^8.0.0 + version: 8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@formkit/auto-animate': specifier: ^0.8.2 version: 0.8.2 @@ -1707,6 +1716,12 @@ packages: '@dnd-kit/core': ^6.0.7 react: '>=16.8.0' + '@dnd-kit/sortable@8.0.0': + resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==} + peerDependencies: + '@dnd-kit/core': ^6.1.0 + react: '>=16.8.0' + '@dnd-kit/utilities@3.2.2': resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} peerDependencies: @@ -13198,6 +13213,13 @@ snapshots: react: 18.3.1 tslib: 2.6.2 + '@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.6.2 + '@dnd-kit/utilities@3.2.2(react@18.3.1)': dependencies: react: 18.3.1 @@ -17213,7 +17235,7 @@ snapshots: '@typescript-eslint/eslint-plugin': 7.17.0(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3) '@typescript-eslint/parser': 7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3) eslint-config-prettier: 9.1.0(eslint@9.10.0(jiti@1.21.6)) - eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@1.21.6))) + eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.29.1) eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@1.21.6)) eslint-plugin-eslint-comments: 3.2.0(eslint@9.10.0(jiti@1.21.6)) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.10.0(jiti@1.21.6)) @@ -18881,8 +18903,8 @@ snapshots: '@typescript-eslint/parser': 8.8.1(eslint@8.57.1)(typescript@5.6.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1) eslint-plugin-react: 7.35.0(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.1) @@ -18901,7 +18923,7 @@ snapshots: eslint: 9.10.0(jiti@1.21.6) eslint-plugin-turbo: 2.1.3(eslint@9.10.0(jiti@1.21.6)) - eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@1.21.6))): + eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1): dependencies: eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.10.0(jiti@1.21.6)) @@ -18918,7 +18940,7 @@ snapshots: debug: 4.3.6 enhanced-resolve: 5.15.0 eslint: 9.10.0(jiti@1.21.6) - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@1.21.6)) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.10.0(jiti@1.21.6)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -18930,13 +18952,13 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1): dependencies: debug: 4.3.6 enhanced-resolve: 5.15.0 eslint: 8.57.1 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -18947,7 +18969,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6)): + eslint-module-utils@2.8.0(@typescript-eslint/parser@7.17.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@1.21.6)): dependencies: debug: 3.2.7 optionalDependencies: @@ -18958,14 +18980,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.8.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.8.1(eslint@8.57.1)(typescript@5.6.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -18985,7 +19007,7 @@ snapshots: eslint: 9.10.0(jiti@1.21.6) ignore: 5.3.1 - eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.3 @@ -18995,7 +19017,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 diff --git a/turbo.json b/turbo.json index 3276d6a0d..8df7bbc21 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,6 @@ "NODE_ENV", "DATABASE_URL", "DIRECT_URL", - "NEXTAUTH_URL", "NEXTAUTH_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", From 31472168aa729ce430c1d20263a658e5902074ab Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 25 Oct 2024 01:03:09 -0400 Subject: [PATCH 06/47] add categories to ai rule ui --- apps/web/app/(app)/automation/RuleForm.tsx | 41 +++++- apps/web/app/(landing)/components/page.tsx | 27 ++++ apps/web/components/MultiSelectFilter.tsx | 161 +++++++++++++++++++++ apps/web/prisma/schema.prisma | 10 ++ apps/web/utils/actions/validation.ts | 7 +- 5 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 apps/web/components/MultiSelectFilter.tsx diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 41b598563..b5c2da8f0 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -27,7 +27,7 @@ import { SectionDescription, TypographyH3, } from "@/components/Typography"; -import { ActionType, RuleType } from "@prisma/client"; +import { ActionType, CategoryFilterType, RuleType } from "@prisma/client"; import { createRuleAction, updateRuleAction } from "@/utils/actions/rule"; import { type CreateRuleBody, @@ -52,6 +52,8 @@ import { Combobox } from "@/components/Combobox"; import { useLabels } from "@/hooks/useLabels"; import { createLabelAction } from "@/utils/actions/mail"; import type { LabelsResponse } from "@/app/api/google/labels/route"; +import { MultiSelectFilter } from "@/components/MultiSelectFilter"; +import { SenderCategory } from "@/app/api/user/categorize/senders/categorize-sender"; export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { @@ -63,7 +65,11 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(createRuleBody), - defaultValues: rule, + defaultValues: { + ...rule, + categoryFilterType: rule.categoryFilterType, + categoryFilter: rule.categoryFilter, + }, }); const { append, remove } = useFieldArray({ control, name: "actions" }); @@ -178,6 +184,37 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { placeholder='e.g. Apply this rule to all "receipts"' tooltipText="The instructions that will be passed to the AI." /> + +
+
+ { const now = useMemo(() => Date.now(), []); @@ -141,13 +142,6 @@ function UnsubscribeRow({ }, ); - const parseEmail = (name: string) => { - const match = name.match(/<(.+)>/); - return match ? match[1] : name; - }; - const name = row.name.split("<")[0].trim(); - const email = parseEmail(row.name); - const readPercentage = row.value ? (row.readEmails / row.value) * 100 : 0; const archivedEmails = row.value - row.inboxEmails; const archivedPercentage = row.value ? (archivedEmails / row.value) * 100 : 0; @@ -157,8 +151,7 @@ function UnsubscribeRow({ return ( -
{name}
-
{email}
+
{row.value} diff --git a/apps/web/app/(app)/categories/page.tsx b/apps/web/app/(app)/smart-categories/page.tsx similarity index 73% rename from apps/web/app/(app)/categories/page.tsx rename to apps/web/app/(app)/smart-categories/page.tsx index 07654293d..2f381386b 100644 --- a/apps/web/app/(app)/categories/page.tsx +++ b/apps/web/app/(app)/smart-categories/page.tsx @@ -3,9 +3,19 @@ import { KanbanBoard } from "@/components/kanban/KanbanBoard"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma from "@/utils/prisma"; import { ClientOnly } from "@/components/ClientOnly"; +import { isDefined } from "@/utils/types"; export const dynamic = "force-dynamic"; +const CATEGORY_ORDER = [ + "unknown", + "request_more_information", + "newsletter", + "marketing", + "receipts", + "support", +]; + export default async function CategoriesPage() { const session = await auth(); const email = session?.user.email; @@ -24,11 +34,19 @@ export default async function CategoriesPage() { if (!categories.length) return
No categories found
; + // Order categories + const orderedCategories = [ + ...CATEGORY_ORDER.map((name) => + categories.find((c) => c.name === name), + ).filter(isDefined), + ...categories.filter((c) => !CATEGORY_ORDER.includes(c.name)), + ]; + return (
({ + categories={orderedCategories.map((c) => ({ id: c.id, title: capitalCase(c.name), }))} diff --git a/apps/web/components/EmailCell.tsx b/apps/web/components/EmailCell.tsx new file mode 100644 index 000000000..68827a91d --- /dev/null +++ b/apps/web/components/EmailCell.tsx @@ -0,0 +1,23 @@ +import { memo } from "react"; + +export const EmailCell = memo(function EmailCell({ + emailAddress, + className, +}: { + emailAddress: string; + className?: string; +}) { + const parseEmail = (name: string) => { + const match = name.match(/<(.+)>/); + return match ? match[1] : name; + }; + const name = emailAddress.split("<")[0].trim(); + const email = parseEmail(emailAddress); + + return ( +
+
{name}
+
{email}
+
+ ); +}); diff --git a/apps/web/components/Select.tsx b/apps/web/components/Select.tsx index 6061b77ec..ce5b369d6 100644 --- a/apps/web/components/Select.tsx +++ b/apps/web/components/Select.tsx @@ -4,6 +4,7 @@ import { ErrorMessage, ExplainText, Label } from "@/components/Input"; interface SelectProps { name: string; label: string; + tooltipText?: string; options: Array<{ label: string; value: T }>; explainText?: string; error?: FieldError; @@ -16,7 +17,13 @@ export function Select( ) { return (
- {props.label ?