Skip to content

Commit

Permalink
Merge branch 'categorise-senders' into next15
Browse files Browse the repository at this point in the history
  • Loading branch information
elie222 committed Oct 30, 2024
2 parents b874da6 + 3d2f80c commit 77dc348
Show file tree
Hide file tree
Showing 86 changed files with 4,034 additions and 398 deletions.
3 changes: 1 addition & 2 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -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=

Expand All @@ -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"
Expand Down
88 changes: 88 additions & 0 deletions apps/web/__tests__/ai-categorize-senders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from "vitest";
import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders";
import { getEnabledCategories } from "@/utils/categories";

vi.mock("server-only", () => ({}));

describe("aiCategorizeSenders", () => {
const user = {
email: "[email protected]",
aiProvider: null,
aiModel: null,
aiApiKey: null,
};

it("should categorize senders using AI", async () => {
const senders = [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
];

const result = await aiCategorizeSenders({
user,
senders: senders.map((sender) => ({ emailAddress: sender, snippet: "" })),
categories: getEnabledCategories().map((c) => c.label),
});

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 === "[email protected]",
);
expect(newsletterResult?.category).toBe("newsletter");

const supportResult = result.find(
(r) => r.sender === "[email protected]",
);
expect(supportResult?.category).toBe("support");

// The unknown sender might be categorized as "RequestMoreInformation"
const unknownResult = result.find(
(r) => r.sender === "[email protected]",
);
expect(unknownResult?.category).toBe("RequestMoreInformation");
}, 15_000); // Increased timeout for AI call

it("should handle empty senders list", async () => {
const result = await aiCategorizeSenders({
user,
senders: [],
categories: [],
});

expect(result).toEqual([]);
});

it("should categorize senders for all valid SenderCategory values", async () => {
const senders = getEnabledCategories()
.filter((category) => category.label !== "Unknown")
.map((category) => `${category.label}@example.com`);

const result = await aiCategorizeSenders({
user,
senders: senders.map((sender) => ({ emailAddress: sender, snippet: "" })),
categories: getEnabledCategories().map((c) => c.label),
});

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);
});
12 changes: 4 additions & 8 deletions apps/web/app/(app)/automation/BulkRunRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

import { useRef, useState } from "react";
import Link from "next/link";
import useSWR from "swr";
import { useAtomValue } from "jotai";
import { HistoryIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useModal, Modal } from "@/components/Modal";
Expand All @@ -12,23 +10,21 @@ import type { ThreadsResponse } from "@/app/api/google/threads/controller";
import type { ThreadsQuery } from "@/app/api/google/threads/validation";
import { LoadingContent } from "@/components/LoadingContent";
import { runAiRules } from "@/utils/queue/email-actions";
import { aiQueueAtom } from "@/store/queue";
import { sleep } from "@/utils/sleep";
import { PremiumAlertWithData, usePremium } from "@/components/PremiumAlert";
import { SetDateDropdown } from "@/app/(app)/automation/SetDateDropdown";
import { dateToSeconds } from "@/utils/date";
import { Tooltip } from "@/components/Tooltip";
import { useThreads } from "@/hooks/useThreads";
import { useAiQueueState } from "@/store/ai-queue";

export function BulkRunRules() {
const { isModalOpen, openModal, closeModal } = useModal();
const [totalThreads, setTotalThreads] = useState(0);

const query: ThreadsQuery = { type: "inbox" };
const { data, isLoading, error } = useSWR<ThreadsResponse>(
`/api/google/threads?${new URLSearchParams(query as any).toString()}`,
);
const { data, isLoading, error } = useThreads({ type: "inbox" });

const queue = useAtomValue(aiQueueAtom);
const queue = useAiQueueState();

const { hasAiAccess, isLoading: isLoadingPremium } = usePremium();

Expand Down
41 changes: 39 additions & 2 deletions apps/web/app/(app)/automation/RuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 "@/utils/categories";

export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
const {
Expand All @@ -63,7 +65,9 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
formState: { errors, isSubmitting },
} = useForm<CreateRuleBody>({
resolver: zodResolver(createRuleBody),
defaultValues: rule,
defaultValues: {
...rule,
},
});

const { append, remove } = useFieldArray({ control, name: "actions" });
Expand Down Expand Up @@ -178,6 +182,39 @@ 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."
/>

<div className="space-y-2">
<div className="w-fit">
<Select
name="categoryFilterType"
label="Only apply rule to emails from these categories"
tooltipText="This helps the AI be more accurate and produce better results."
options={[
{ label: "Include", value: CategoryFilterType.INCLUDE },
{ label: "Exclude", value: CategoryFilterType.EXCLUDE },
]}
registerProps={register("categoryFilterType")}
error={errors.categoryFilterType}
/>
</div>

<MultiSelectFilter
title="Categories"
maxDisplayedValues={8}
// TODO: load sender categories from backend
options={Object.values(senderCategory).map((category) => ({
label: capitalCase(category.label),
value: category.label,
}))}
selectedValues={new Set(watch("categoryFilters"))}
setSelectedValues={(selectedValues) => {
setValue("categoryFilters", Array.from(selectedValues));
}}
/>
{errors.categoryFilters?.message && (
<ErrorMessage message={errors.categoryFilters.message} />
)}
</div>
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(app)/automation/RulesPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
saveRulesPromptAction,
generateRulesPromptAction,
} from "@/utils/actions/ai-rule";
import { captureException, isActionError } from "@/utils/error";
import { isActionError } from "@/utils/error";
import {
Card,
CardContent,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(app)/automation/group/CreateGroupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { type SubmitHandler, useForm } from "react-hook-form";
import { useSWRConfig } from "swr";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { Modal, useModal } from "@/components/Modal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/Input";
Expand All @@ -13,7 +14,6 @@ import {
createNewsletterGroupAction,
createReceiptGroupAction,
} from "@/utils/actions/group";
import { zodResolver } from "@hookform/resolvers/zod";
import {
type CreateGroupBody,
createGroupBody,
Expand Down
7 changes: 4 additions & 3 deletions apps/web/app/(app)/bulk-unsubscribe/ArchiveProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import { memo, useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useAtomValue } from "jotai";
import { ProgressBar } from "@tremor/react";
import { queueAtom, resetTotalThreads } from "@/store/archive-queue";
import { resetTotalThreads, useQueueState } from "@/store/archive-queue";
import { cn } from "@/utils";

export const ArchiveProgress = memo(() => {
const { totalThreads, activeThreads } = useAtomValue(queueAtom);
const { totalThreads, activeThreads } = useQueueState();

// Make sure activeThreads is an object as this was causing an error.
const threadsRemaining = Object.values(activeThreads || {}).length;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ActionBar } from "@/app/(app)/stats/ActionBar";
import { useStatLoader } from "@/providers/StatLoaderProvider";
import { OnboardingModal } from "@/components/OnboardingModal";
import { TextLink } from "@/components/Typography";
import { TopBar } from "@/components/TopBar";

const selectOptions = [
{ label: "Last week", value: "7" },
Expand Down Expand Up @@ -52,7 +53,7 @@ export function BulkUnsubscribe() {

return (
<div>
<div className="top-0 z-10 flex flex-col justify-between gap-1 border-b bg-white px-2 py-2 shadow sm:sticky sm:flex-row sm:px-4">
<TopBar sticky>
<OnboardingModal
title="Getting started with Bulk Unsubscribe"
description={
Expand All @@ -79,7 +80,7 @@ export function BulkUnsubscribe() {
/>
<LoadStatsButton />
</div>
</div>
</TopBar>

<div className="my-2 sm:mx-4 sm:my-4">
<BulkUnsubscribeSection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
38 changes: 28 additions & 10 deletions apps/web/app/(app)/bulk-unsubscribe/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import { decrementUnsubscribeCreditAction } from "@/utils/actions/premium";
import { NewsletterStatus } from "@prisma/client";
import { cleanUnsubscribeLink } from "@/utils/parse/parseHtml.client";
import { captureException } from "@/utils/error";
import {
archiveAllSenderEmails,
deleteEmails,
} from "@/utils/queue/email-actions";
import { addToArchiveSenderQueue } from "@/store/archive-sender-queue";
import { deleteEmails } from "@/store/archive-queue";
import type { Row } from "@/app/(app)/bulk-unsubscribe/types";
import type { GetThreadsResponse } from "@/app/api/google/threads/basic/route";
import { isDefined } from "@/utils/types";
Expand All @@ -29,7 +27,7 @@ async function unsubscribeAndArchive(
await mutate();
await decrementUnsubscribeCreditAction();
await refetchPremium();
await archiveAllSenderEmails(newsletterEmail, () => {});
await addToArchiveSenderQueue(newsletterEmail);
}

export function useUnsubscribe<T extends Row>({
Expand Down Expand Up @@ -143,7 +141,7 @@ async function autoArchive(
await mutate();
await decrementUnsubscribeCreditAction();
await refetchPremium();
await archiveAllSenderEmails(name, () => {}, labelId);
await addToArchiveSenderQueue(name, labelId);
}

export function useAutoArchive<T extends Row>({
Expand Down Expand Up @@ -307,14 +305,25 @@ export function useBulkApprove<T extends Row>({
async function archiveAll(name: string, onFinish: () => void) {
toast.promise(
async () => {
const data = await archiveAllSenderEmails(name, onFinish);
return data.length;
const threadsArchived = await new Promise<number>((resolve, reject) => {
addToArchiveSenderQueue(
name,
undefined,
(totalThreads) => {
onFinish();
resolve(totalThreads);
},
reject,
);
});

return threadsArchived;
},
{
loading: `Archiving all emails from ${name}`,
success: (data) =>
data
? `Archiving ${data} emails from ${name}...`
? `Archived ${data} emails from ${name}`
: `No emails to archive from ${name}`,
error: `There was an error archiving the emails from ${name} :(`,
},
Expand Down Expand Up @@ -373,7 +382,16 @@ async function deleteAllFromSender(name: string, onFinish: () => void) {

// 2. delete messages
if (data?.length) {
deleteEmails(data.map((t) => t.id).filter(isDefined), onFinish);
await new Promise<void>((resolve, reject) => {
deleteEmails(
data.map((t) => t.id).filter(isDefined),
() => {
onFinish();
resolve();
},
reject,
);
});
}

return data.length;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(app)/compose/selectors/color-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export const ColorSelector = ({ open, onOpenChange }: any) => {

<PopoverContent
sideOffset={5}
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl "
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl"
align="start"
>
<div className="flex flex-col">
Expand Down
Loading

0 comments on commit 77dc348

Please sign in to comment.