Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
19cfa6a
Find batch of senders
elie222 Oct 22, 2024
0e17cb1
paginate through senders
elie222 Oct 22, 2024
d8b112b
Merge branch 'main' into categorise-senders
elie222 Oct 23, 2024
5a8002d
Merge branch 'main' into categorise-senders
elie222 Oct 23, 2024
b780aa0
add ai categorise sender function
elie222 Oct 23, 2024
8b61812
rename categorise to be consistently categorize
elie222 Oct 23, 2024
46d2d16
basic categorisation of senders and display in ui
elie222 Oct 25, 2024
3147216
add categories to ai rule ui
elie222 Oct 25, 2024
ce8b6e0
filter rules to run by category filter
elie222 Oct 25, 2024
3c9dcba
display categories page in sidebar
elie222 Oct 25, 2024
fe84710
show categorised emails table
elie222 Oct 25, 2024
7d788f7
Collapse categories
elie222 Oct 26, 2024
b48585b
use hook
elie222 Oct 27, 2024
5d3f24e
fix search params undefined bug
elie222 Oct 27, 2024
d53e30c
improve sender categorization
elie222 Oct 27, 2024
385eb14
create category
elie222 Oct 27, 2024
4b230a8
prettier error for duplicate category
elie222 Oct 27, 2024
bcbcd70
refactor
elie222 Oct 27, 2024
80527dd
set up categories
elie222 Oct 27, 2024
6b5e37b
categorise senders step
elie222 Oct 27, 2024
0a6f37c
show category description
elie222 Oct 27, 2024
00979d1
add description upon create
elie222 Oct 27, 2024
4d808d4
use description when categorising
elie222 Oct 27, 2024
b0fd2f1
Allow adding description for category upon create
elie222 Oct 27, 2024
40d160b
show uncategorised senders
elie222 Oct 28, 2024
25b3db5
create senders table
elie222 Oct 28, 2024
550ee63
categorize uncategorized
elie222 Oct 28, 2024
39b8aba
add queue for categorize
elie222 Oct 28, 2024
f9c298f
show category that's been chosen for unprocessed items
elie222 Oct 28, 2024
dbacf99
load uncategorized emails from tinybird
elie222 Oct 28, 2024
11efc5f
don't automaticallly assume that support at is a support email
elie222 Oct 28, 2024
451d692
adjust prompt
elie222 Oct 28, 2024
7a5402e
Merge branch 'main' into categorise-senders
elie222 Oct 29, 2024
864b90b
delete old files
elie222 Oct 29, 2024
54f5fd8
adjust prompt
elie222 Oct 29, 2024
89bc246
add archive all for category
elie222 Oct 29, 2024
3c940a2
unify how archiveAllSenderEmails works across the app
elie222 Oct 29, 2024
6b882c7
fix ts build error
elie222 Oct 29, 2024
bf2a7e6
add security headers
elie222 Oct 29, 2024
1fc1c46
show archive progress on categories page and clean up atoms
elie222 Oct 29, 2024
088cb9c
fix empty
elie222 Oct 29, 2024
97edbbe
fix broken reactivity
elie222 Oct 29, 2024
36605e9
show how many emails were archived for a sender
elie222 Oct 29, 2024
8ba641f
categories: Open in Gmail when clicking an email or sender
elie222 Oct 29, 2024
0f79134
fix ts
elie222 Oct 29, 2024
bf60aa8
Add onboarding for smart categories
elie222 Oct 29, 2024
f42e85c
Add load more for uncategorized
elie222 Oct 29, 2024
bd6ce39
stop categorization
elie222 Oct 29, 2024
e830b34
show premium banner for categorize page
elie222 Oct 30, 2024
3d2f80c
disable categorize button if not premium
elie222 Oct 30, 2024
2123bb2
fix tests
elie222 Oct 30, 2024
3ede6bc
don't include sent emails when categorizing
elie222 Oct 30, 2024
3eef8a3
Better checking of email for cold email blocker
elie222 Oct 30, 2024
db84277
add more public domains
elie222 Oct 30, 2024
f73b85f
Run cold email blocker before running ai rules
elie222 Oct 30, 2024
b2d1a63
categorize new senders as they come in
elie222 Oct 30, 2024
fbc37d8
Don't categorize if no categories exist
elie222 Oct 30, 2024
7e802c5
move set up categories to its own page
elie222 Oct 30, 2024
1a9a6a5
fix next/router error
elie222 Oct 30, 2024
2122c13
add your own category in onboarding
elie222 Oct 30, 2024
1edbf31
more category examples
elie222 Oct 30, 2024
1ba9d3d
Merge branch 'main' into categorise-senders
elie222 Oct 31, 2024
d71a62e
add your own categories in set up
elie222 Oct 31, 2024
f072ca2
run biome fix
elie222 Oct 31, 2024
9cd566f
Merge branch 'main' into categorise-senders
elie222 Oct 31, 2024
6af644f
Merge branch 'main' into categorise-senders
elie222 Oct 31, 2024
1160fca
biome fixes
elie222 Oct 31, 2024
2f196e1
load categories from server. more biome fixes
elie222 Oct 31, 2024
873ecea
more biome fixes
elie222 Oct 31, 2024
b08b584
Edit categories link
elie222 Oct 31, 2024
4615449
reorder set up categories
elie222 Oct 31, 2024
24f27cd
adjust category descriptions
elie222 Oct 31, 2024
3ca6093
Update category
elie222 Oct 31, 2024
7f66d56
better use of category id
elie222 Oct 31, 2024
49c829b
Fix set up categories
elie222 Oct 31, 2024
df626d6
auto categorize toggle
elie222 Oct 31, 2024
bcc6a31
smart categories early access
elie222 Nov 1, 2024
f4558e6
smart category feature flag in more places
elie222 Nov 1, 2024
42df19e
put feature flags in single file
elie222 Nov 1, 2024
2054b5f
Link to create categories from form
elie222 Nov 1, 2024
5bd1f63
undo spread change
elie222 Nov 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .vscode/typescriptreact.code-snippets
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"",
"export const GET = withError(async (request) => {",
" const session = await auth();",
" if (!session) return NextResponse.json({ error: \"Not authenticated\" });",
" if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });",
"",
" const result = await get${1:ApiName}({ userId: session.user.id });",
"",
Expand All @@ -63,7 +63,7 @@
"",
"export const POST = withError(async (request) => {",
" const session = await auth();",
" if (!session) return NextResponse.json({ error: \"Not authenticated\" });",
" if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });",
"",
" const json = await request.json();",
" const body = ${1/(.*)/${1:/downcase}/}Body.parse(json);",
Expand All @@ -82,7 +82,7 @@
"",
"export const DELETE = withError(async (_request, { params }) => {",
" const session = await auth();",
" if (!session) return NextResponse.json({ error: \"Not authenticated\" });",
" if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });",
"",
" const result = await prisma.${2:table}.delete({",
" where: {",
Expand Down Expand Up @@ -222,7 +222,7 @@
"",
"export const POST = withError(async (request: Request) => {",
" const session = await auth();",
" if (!session) return NextResponse.json({ error: \"Not authenticated\" });",
" if (!session?.user) return NextResponse.json({ error: \"Not authenticated\" });",
"",
" const json = await request.json();",
" const body = saveSettingsBody.parse(json);",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,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
97 changes: 97 additions & 0 deletions apps/web/__tests__/ai-categorize-senders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi } from "vitest";
import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders";
import { defaultCategory } from "@/utils/categories";

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

describe("aiCategorizeSenders", () => {
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 aiCategorizeSenders({
user,
senders: senders.map((sender) => ({ emailAddress: sender, snippet: "" })),
categories: getEnabledCategories(),
});

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 "RequestMoreInformation"
const unknownResult = result.find(
(r) => r.sender === "unknown@example.com",
);
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.name !== "Unknown")
.map((category) => `${category.name}@example.com`);

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

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);
});

const getEnabledCategories = () => {
return Object.entries(defaultCategory)
.filter(([_, value]) => value.enabled)
.map(([_, value]) => ({
name: value.name,
description: value.description,
}));
};
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
76 changes: 66 additions & 10 deletions apps/web/app/(app)/automation/RuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { capitalCase } from "capital-case";
import { usePostHog } from "posthog-js/react";
import { HelpCircleIcon, PlusIcon } from "lucide-react";
import { ExternalLinkIcon, PlusIcon } from "lucide-react";
import { Card } from "@/components/Card";
import { Button } from "@/components/ui/button";
import { ErrorMessage, Input, Label } from "@/components/Input";
Expand All @@ -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 @@ -36,7 +36,6 @@ import {
import { actionInputs } from "@/utils/actionType";
import { Select } from "@/components/Select";
import { Toggle } from "@/components/Toggle";
import { Tooltip } from "@/components/Tooltip";
import type { GroupsResponse } from "@/app/api/user/group/route";
import { LoadingContent } from "@/components/LoadingContent";
import { TooltipExplanation } from "@/components/TooltipExplanation";
Expand All @@ -52,6 +51,9 @@ 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 { useCategories } from "@/hooks/useCategories";
import { useSmartCategoriesEnabled } from "@/hooks/useFeatureFlags";

export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
const {
Expand All @@ -69,7 +71,11 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
const { append, remove } = useFieldArray({ control, name: "actions" });

const { userLabels, data: gmailLabelsData, isLoading, mutate } = useLabels();

const {
categories,
isLoading: categoriesLoading,
error: categoriesError,
} = useCategories();
const router = useRouter();

const posthog = usePostHog();
Expand Down Expand Up @@ -136,6 +142,8 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
[gmailLabelsData?.labels, router, posthog],
);

const showSmartCategories = useSmartCategoriesEnabled();

return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mt-4">
Expand Down Expand Up @@ -178,6 +186,52 @@ 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."
/>

{showSmartCategories && (
<div className="space-y-2">
<div className="w-fit">
<Select
name="categoryFilterType"
label="Optional: 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>

<LoadingContent
loading={categoriesLoading}
error={categoriesError}
>
<MultiSelectFilter
title="Categories"
maxDisplayedValues={8}
options={categories.map((category) => ({
label: capitalCase(category.name),
value: category.id,
}))}
selectedValues={new Set(watch("categoryFilters"))}
setSelectedValues={(selectedValues) => {
setValue("categoryFilters", Array.from(selectedValues));
}}
/>
{errors.categoryFilters?.message && (
<ErrorMessage message={errors.categoryFilters.message} />
)}
</LoadingContent>

<Button asChild variant="ghost" size="sm" className="ml-2">
<Link href="/smart-categories/setup" target="_blank">
Create new category
<ExternalLinkIcon className="ml-1.5 size-4" />
</Link>
</Button>
</div>
)}
</div>
)}

Expand Down Expand Up @@ -341,9 +395,10 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
</div>

<div className="mt-4 flex items-center justify-end space-x-2">
<Tooltip content="When enabled our AI will perform actions automatically. If disabled, you will have to confirm actions first.">
<HelpCircleIcon className="h-5 w-5 cursor-pointer" />
</Tooltip>
<TooltipExplanation
size="md"
text="When enabled our AI will perform actions automatically. If disabled, you will have to confirm actions first."
/>

<Toggle
name="automate"
Expand All @@ -356,9 +411,10 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
</div>

<div className="mt-4 flex items-center justify-end space-x-2">
<Tooltip content="When enabled, this rule applies to all emails in a conversation, including replies. When disabled, it only applies to the first email in each conversation.">
<HelpCircleIcon className="h-5 w-5 cursor-pointer" />
</Tooltip>
<TooltipExplanation
size="md"
text="When enabled, this rule applies to all emails in a conversation, including replies. When disabled, it only applies to the first email in each conversation."
/>

<Toggle
name="runOnThreads"
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
Loading