diff --git a/.cursorrules b/.cursorrules index 28506f2643..c9ba4e1c8f 100644 --- a/.cursorrules +++ b/.cursorrules @@ -208,4 +208,13 @@ export const POST = withError(async (request: Request) => { return NextResponse.json(result); }); -``` \ No newline at end of file +``` + +## Utility Functions + +- Use lodash utilities for common operations (arrays, objects, strings) +- Import specific lodash functions to minimize bundle size: + ```ts + import groupBy from "lodash/groupBy"; + ``` +- Create utility functions in `utils/` folder for reusable logic \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 725dafbb1f..9fedadf7c0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -160,6 +160,32 @@ The project extensively uses environment variables for configuration. These vari - Admin email addresses. - Webhook URLs and API keys for internal communication. -## Conclusion +## Features -Inbox Zero exhibits a well-structured and modular architecture, leveraging a monorepo approach and modern technologies like Next.js, Prisma, and serverless functions. The architecture is designed for scalability, maintainability, and extensibility, with clear separation of concerns between the frontend, backend, and supporting services. The use of queues and background processing ensures efficient handling of asynchronous tasks like AI processing and email actions. The extensive use of environment variables promotes configuration flexibility and security. This architecture is well-suited for a complex SaaS application like Inbox Zero, providing a solid foundation for future development and feature enhancements. +### AI Personal Assistant + +The user can set a prompt file which gets converted to individual rules in our database. +What is ultimately passed to the LLM is the database rules and not the prompt file. +We have a two way sync system between the db rules and the prompt file. This is messy, and maybe it would be better to just have one-way data flow via the prompt file. + +The benefit to having database rules: + +- In most cases, the AI is only deciding if conditions are matched. +- We have specific entries for each rule, so we can track how often each is called. If it were fully prompt based this wouldn't be possible. This is a potentially minor benefit to the user however. +- Because actions are static (unless using templates), the user can precisely define how the actions work without any LLM interference. + +The current structure of the AI personal assistant is due to the product evolving. Had it been designed from scratch it would likely have been structured a little differently to avoid the two-way sync issues. This architecture may be changed in the future. + +Another downside of not using the prompt file as the source of truth for the LLM is that some information included in the prompt file will not be passed to the LLM. Not something the user expects. For example, the user might write style guidelines at the top of the prompt file, but there's no natural way for this to be moved into the rules, as this information applies to all rules. We do have an `about` section that can be used for this on the `Settings` page, but this is separate. + +### Reply Tracking + +This feature is built off of the AI personal assistant. +There's a special type of rule for reply tracking. +I considered making it a separate feature similar to the cold email blocker. It makes things a little messy having this special type of rule, but the benefit is it integrates with the existing assistant and all the features built around that now. +This means each user has their own reply tracking prompt (but this is also annoying, because it makes it hard for us to do a global update for all users for the prompt, which is something we can do for the cold email blocker prompt). + +### Cold email blocker + +The cold email blocker monitors for incoming emails, if the user has never sent us an email before we run it through an LLM to decide if it's a cold email or not. +This feature is not connected to the AI personal assistant. diff --git a/apps/web/__tests__/ai-choose-args.test.ts b/apps/web/__tests__/ai-choose-args.test.ts index 709fc682fc..7a2cb093c5 100644 --- a/apps/web/__tests__/ai-choose-args.test.ts +++ b/apps/web/__tests__/ai-choose-args.test.ts @@ -163,12 +163,6 @@ function getAction(action: Partial = {}): Action { cc: null, bcc: null, url: null, - labelPrompt: null, - subjectPrompt: null, - contentPrompt: null, - toPrompt: null, - ccPrompt: null, - bccPrompt: null, ...action, }; } @@ -196,6 +190,7 @@ function getRule( categoryFilterType: null, conditionalOperator: LogicalOperator.AND, type: null, + trackReplies: null, }; } diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index 1d62da7338..90c0efa91d 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -74,13 +74,6 @@ describe.skipIf(!isAiTest)("aiChooseRule", () => { cc: null, bcc: null, url: null, - - labelPrompt: null, - subjectPrompt: null, - contentPrompt: null, - toPrompt: null, - ccPrompt: null, - bccPrompt: null, }, ]); diff --git a/apps/web/__tests__/ai-process-user-request.test.ts b/apps/web/__tests__/ai-process-user-request.test.ts index 0867492878..8b6b8123a7 100644 --- a/apps/web/__tests__/ai-process-user-request.test.ts +++ b/apps/web/__tests__/ai-process-user-request.test.ts @@ -451,6 +451,7 @@ function getRule(rule: Partial): RuleWithRelations { type: RuleType.AI, createdAt: new Date(), updatedAt: new Date(), + trackReplies: null, ...rule, }; } diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx index 6bf25e224a..ef50c8f1f7 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailList.tsx @@ -2,8 +2,6 @@ import { useCallback, useState } from "react"; import useSWR from "swr"; -import Link from "next/link"; -import { ExternalLinkIcon } from "lucide-react"; import { useSession } from "next-auth/react"; import { LoadingContent } from "@/components/LoadingContent"; import type { ColdEmailsResponse } from "@/app/api/user/cold-email/route"; @@ -18,21 +16,16 @@ import { import { DateCell } from "@/app/(app)/automation/ExecutedRulesTable"; import { TablePagination } from "@/components/TablePagination"; import { AlertBasic } from "@/components/Alert"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { getGmailSearchUrl } from "@/utils/url"; import { Button } from "@/components/ui/button"; import { useSearchParams } from "next/navigation"; import { markNotColdEmailAction } from "@/utils/actions/cold-email"; -import { SectionDescription } from "@/components/Typography"; import { Checkbox } from "@/components/Checkbox"; import { useToggleSelect } from "@/hooks/useToggleSelect"; import { handleActionResult } from "@/utils/server-action"; import { useUser } from "@/hooks/useUser"; import { ViewEmailButton } from "@/components/ViewEmailButton"; -import { - EmailMessageCell, - EmailMessageCellWithData, -} from "@/components/EmailMessageCell"; +import { EmailMessageCellWithData } from "@/components/EmailMessageCell"; +import { EnableFeatureCard } from "@/components/EnableFeatureCard"; export function ColdEmailList() { const searchParams = useSearchParams(); @@ -213,16 +206,15 @@ function NoColdEmails() { if (!data?.coldEmailBlocker || data?.coldEmailBlocker === "DISABLED") { return ( -
- - Cold email blocker is disabled. Enable it to start blocking cold - emails. - - +
+
); } diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx index be8d2d8019..07933ff8b0 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx @@ -55,7 +55,7 @@ export function ColdEmailPromptForm(props: { ); return ( -
+ {data && ( - <> +
- +
)} ); diff --git a/apps/web/app/(app)/compose/ComposeEmailForm.tsx b/apps/web/app/(app)/compose/ComposeEmailForm.tsx index 1887eabba2..0adc741953 100644 --- a/apps/web/app/(app)/compose/ComposeEmailForm.tsx +++ b/apps/web/app/(app)/compose/ComposeEmailForm.tsx @@ -7,26 +7,10 @@ import { ComboboxOptions, } from "@headlessui/react"; import { CheckCircleIcon, TrashIcon, XIcon } from "lucide-react"; -import { - EditorBubble, - EditorCommand, - EditorCommandEmpty, - EditorCommandItem, - EditorContent, - EditorRoot, -} from "novel"; -import { handleCommandNavigation } from "novel/extensions"; -import React, { useCallback, useState } from "react"; +import React, { useCallback } from "react"; import { type SubmitHandler, useForm } from "react-hook-form"; import useSWR from "swr"; import { z } from "zod"; - -import { defaultExtensions } from "@/app/(app)/compose/extensions"; -import { ColorSelector } from "@/app/(app)/compose/selectors/color-selector"; -import { LinkSelector } from "@/app/(app)/compose/selectors/link-selector"; -import { NodeSelector } from "@/app/(app)/compose/selectors/node-selector"; -// import { AISelector } from "@/app/(app)/compose/selectors/ai-selector"; -import { TextButtons } from "@/app/(app)/compose/selectors/text-buttons"; import type { ContactsResponse } from "@/app/api/google/contacts/route"; import { Input, Label } from "@/components/Input"; import { toastError, toastSuccess } from "@/components/Toast"; @@ -36,16 +20,11 @@ import { Button } from "@/components/ui/button"; import { ButtonLoader } from "@/components/Loading"; import { env } from "@/env"; import { cn } from "@/utils"; -import { postRequest } from "@/utils/api"; import { extractNameFromEmail } from "@/utils/email"; -import { isError } from "@/utils/error"; -import type { SendEmailBody, SendEmailResponse } from "@/utils/gmail/mail"; -import { - slashCommand, - suggestionItems, -} from "@/app/(app)/compose/SlashCommand"; -import { Separator } from "@/components/ui/separator"; -import "@/styles/prosemirror.css"; +import { isActionError } from "@/utils/error"; +import type { SendEmailBody } from "@/utils/gmail/mail"; +import { Tiptap } from "@/components/Tiptap"; +import { sendEmailAction } from "@/utils/actions/mail"; export type ReplyingToEmail = { threadId: string; @@ -58,21 +37,19 @@ export type ReplyingToEmail = { messageHtml?: string | undefined; }; -export const ComposeEmailForm = (props: { +export const ComposeEmailForm = ({ + replyingToEmail, + submitButtonClassName, + refetch, + onSuccess, + onDiscard, +}: { replyingToEmail?: ReplyingToEmail; - novelEditorClassName?: string; submitButtonClassName?: string; refetch?: () => void; onSuccess?: () => void; onDiscard?: () => void; }) => { - const { refetch, onSuccess } = props; - - const [openNode, setOpenNode] = useState(false); - const [openColor, setOpenColor] = useState(false); - const [openLink, setOpenLink] = useState(false); - // const [openAi, setOpenAi] = useState(false); - const { register, handleSubmit, @@ -81,10 +58,12 @@ export const ComposeEmailForm = (props: { setValue, } = useForm({ defaultValues: { - replyToEmail: props.replyingToEmail, - subject: props.replyingToEmail?.subject, - to: props.replyingToEmail?.to, - cc: props.replyingToEmail?.cc, + replyToEmail: replyingToEmail, + subject: replyingToEmail?.subject, + to: replyingToEmail?.to, + cc: replyingToEmail?.cc, + messageHtml: "", + messageText: "", }, }); @@ -92,16 +71,14 @@ export const ComposeEmailForm = (props: { async (data) => { const enrichedData = { ...data, - messageText: data.messageText + props.replyingToEmail?.messageText, + messageText: data.messageText + replyingToEmail?.messageText, messageHtml: - (data.messageHtml ?? "") + (props.replyingToEmail?.messageHtml ?? ""), + (data.messageHtml ?? "") + (replyingToEmail?.messageHtml ?? ""), }; + try { - const res = await postRequest( - "/api/google/messages/send", - enrichedData, - ); - if (isError(res)) + const res = await sendEmailAction(enrichedData); + if (isActionError(res)) toastError({ description: "There was an error sending the email :(", }); @@ -118,8 +95,8 @@ export const ComposeEmailForm = (props: { [ refetch, onSuccess, - props.replyingToEmail?.messageHtml, - props.replyingToEmail?.messageText, + replyingToEmail?.messageHtml, + replyingToEmail?.messageText, ], ); @@ -156,12 +133,21 @@ export const ComposeEmailForm = (props: { const [editReply, setEditReply] = React.useState(false); + const handleEditorChange = useCallback( + (html: string) => { + setValue("messageHtml", html); + // Also set plain text version by stripping HTML tags + setValue("messageText", html.replace(/<[^>]*>/g, "")); + }, + [setValue], + ); + return ( - - {props.replyingToEmail?.to && !editReply ? ( + + {replyingToEmail?.to && !editReply ? ( ) : ( <> @@ -296,91 +282,31 @@ export const ComposeEmailForm = (props: { )} - - {/* TODO onUpdate runs on every change. In most cases, you will want to debounce the updates to prevent too many state changes. */} - { - setValue("messageText", editor.getText()); - setValue("messageHtml", editor.getHTML()); - }} - className={cn( - "relative min-h-32 w-full max-w-screen-lg rounded-xl border bg-background sm:rounded-lg", - props.novelEditorClassName, - )} - editorProps={{ - handleDOMEvents: { - keydown: (_view, event) => handleCommandNavigation(event), - }, - attributes: { - class: - "prose-lg prose-stone dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full", - }, - }} - > - - - No results - - {suggestionItems.map((item) => ( - item.command?.(val)} - className={ - "flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent aria-selected:bg-accent" - } - key={item.title} - > -
- {item.icon} -
-
-

{item.title}

-

- {item.description} -

-
-
- ))} -
- - - - - - - - - - - {/* - */} - -
-
+
- - {props.onDiscard && ( + {onDiscard && ( - - - -
-
- Color -
- {TEXT_COLORS.map(({ name, color }, index) => ( - { - editor.commands.unsetColor(); - name !== "Default" && - editor - .chain() - .focus() - .setColor(color || "") - .run(); - }} - className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent" - > -
-
- A -
- {name} -
-
- ))} -
-
-
- Background -
- {HIGHLIGHT_COLORS.map(({ name, color }, index) => ( - { - editor.commands.unsetHighlight(); - name !== "Default" && editor.commands.setHighlight({ color }); - }} - className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent" - > -
-
- A -
- {name} -
- {editor.isActive("highlight", { color }) && ( - - )} -
- ))} -
-
- - ); -}; diff --git a/apps/web/app/(app)/compose/selectors/link-selector.tsx b/apps/web/app/(app)/compose/selectors/link-selector.tsx deleted file mode 100644 index a570d9c0d2..0000000000 --- a/apps/web/app/(app)/compose/selectors/link-selector.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useEditor } from "novel"; -import { Check, Trash } from "lucide-react"; -import { useEffect, useRef } from "react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/utils"; - -export function isValidUrl(url: string) { - try { - new URL(url); - return true; - } catch (e) { - return false; - } -} -export function getUrlFromString(str: string) { - if (isValidUrl(str)) return str; - try { - if (str.includes(".") && !str.includes(" ")) { - return new URL(`https://${str}`).toString(); - } - } catch (e) { - return null; - } -} -interface LinkSelectorProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { - const inputRef = useRef(null); - const { editor } = useEditor(); - - // Autofocus on input by default - useEffect(() => { - inputRef.current?.focus(); - }); - if (!editor) return null; - - return ( - - - - - - { - const target = e.currentTarget as HTMLFormElement; - e.preventDefault(); - const input = target[0] as HTMLInputElement; - const url = getUrlFromString(input.value); - url && editor.chain().focus().setLink({ href: url }).run(); - }} - className="flex p-1" - > - - {editor.getAttributes("link").href ? ( - - ) : ( - - )} - - - - ); -}; diff --git a/apps/web/app/(app)/compose/selectors/node-selector.tsx b/apps/web/app/(app)/compose/selectors/node-selector.tsx deleted file mode 100644 index 61134db96f..0000000000 --- a/apps/web/app/(app)/compose/selectors/node-selector.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Check, - ChevronDown, - Heading1, - Heading2, - Heading3, - TextQuote, - ListOrdered, - TextIcon, - Code, - CheckSquare, - type LucideIcon, -} from "lucide-react"; -import { EditorBubbleItem, useEditor } from "novel"; - -export type SelectorItem = { - name: string; - icon: LucideIcon; - command: ( - editor: NonNullable["editor"]>, - ) => void; - isActive: ( - editor: NonNullable["editor"]>, - ) => boolean; -}; - -const items: SelectorItem[] = [ - { - name: "Text", - icon: TextIcon, - command: (editor) => - editor.chain().focus().toggleNode("paragraph", "paragraph").run(), - // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! - isActive: (editor) => - editor.isActive("paragraph") && - !editor.isActive("bulletList") && - !editor.isActive("orderedList"), - }, - { - name: "Heading 1", - icon: Heading1, - command: (editor) => - editor.chain().focus().toggleHeading({ level: 1 }).run(), - isActive: (editor) => editor.isActive("heading", { level: 1 }), - }, - { - name: "Heading 2", - icon: Heading2, - command: (editor) => - editor.chain().focus().toggleHeading({ level: 2 }).run(), - isActive: (editor) => editor.isActive("heading", { level: 2 }), - }, - { - name: "Heading 3", - icon: Heading3, - command: (editor) => - editor.chain().focus().toggleHeading({ level: 3 }).run(), - isActive: (editor) => editor.isActive("heading", { level: 3 }), - }, - { - name: "To-do List", - icon: CheckSquare, - command: (editor) => editor.chain().focus().toggleTaskList().run(), - isActive: (editor) => editor.isActive("taskItem"), - }, - { - name: "Bullet List", - icon: ListOrdered, - command: (editor) => editor.chain().focus().toggleBulletList().run(), - isActive: (editor) => editor.isActive("bulletList"), - }, - { - name: "Numbered List", - icon: ListOrdered, - command: (editor) => editor.chain().focus().toggleOrderedList().run(), - isActive: (editor) => editor.isActive("orderedList"), - }, - { - name: "Quote", - icon: TextQuote, - command: (editor) => - editor - .chain() - .focus() - .toggleNode("paragraph", "paragraph") - .toggleBlockquote() - .run(), - isActive: (editor) => editor.isActive("blockquote"), - }, - { - name: "Code", - icon: Code, - command: (editor) => editor.chain().focus().toggleCodeBlock().run(), - isActive: (editor) => editor.isActive("codeBlock"), - }, -]; -interface NodeSelectorProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => { - const { editor } = useEditor(); - if (!editor) return null; - const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? { - name: "Multiple", - }; - - return ( - - - - - - {items.map((item, index) => ( - { - item.command(editor); - onOpenChange(false); - }} - className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent" - > -
-
- -
- {item.name} -
- {activeItem.name === item.name && } -
- ))} -
-
- ); -}; diff --git a/apps/web/app/(app)/compose/selectors/text-buttons.tsx b/apps/web/app/(app)/compose/selectors/text-buttons.tsx deleted file mode 100644 index 6d606f7f8f..0000000000 --- a/apps/web/app/(app)/compose/selectors/text-buttons.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { EditorBubbleItem, useEditor } from "novel"; -import { - BoldIcon, - ItalicIcon, - UnderlineIcon, - StrikethroughIcon, - CodeIcon, -} from "lucide-react"; -import type { SelectorItem } from "./node-selector"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/utils"; - -export const TextButtons = () => { - const { editor } = useEditor(); - if (!editor) return null; - const items: SelectorItem[] = [ - { - name: "bold", - isActive: (editor) => editor.isActive("bold"), - command: (editor) => editor.chain().focus().toggleBold().run(), - icon: BoldIcon, - }, - { - name: "italic", - isActive: (editor) => editor.isActive("italic"), - command: (editor) => editor.chain().focus().toggleItalic().run(), - icon: ItalicIcon, - }, - { - name: "underline", - isActive: (editor) => editor.isActive("underline"), - command: (editor) => editor.chain().focus().toggleUnderline().run(), - icon: UnderlineIcon, - }, - { - name: "strike", - isActive: (editor) => editor.isActive("strike"), - command: (editor) => editor.chain().focus().toggleStrike().run(), - icon: StrikethroughIcon, - }, - { - name: "code", - isActive: (editor) => editor.isActive("code"), - command: (editor) => editor.chain().focus().toggleCode().run(), - icon: CodeIcon, - }, - ]; - return ( -
- {items.map((item, index) => ( - { - item.command(editor); - }} - > - - - ))} -
- ); -}; diff --git a/apps/web/app/(app)/reply-tracker/AwaitingReply.tsx b/apps/web/app/(app)/reply-tracker/AwaitingReply.tsx new file mode 100644 index 0000000000..47ee64a48b --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/AwaitingReply.tsx @@ -0,0 +1,32 @@ +import { ThreadTrackerType } from "@prisma/client"; +import { ReplyTrackerEmails } from "@/app/(app)/reply-tracker/ReplyTrackerEmails"; +import { getPaginatedThreadTrackers } from "@/app/(app)/reply-tracker/fetch-trackers"; +import type { TimeRange } from "@/app/(app)/reply-tracker/date-filter"; + +export async function AwaitingReply({ + userId, + userEmail, + page, + timeRange, +}: { + userId: string; + userEmail: string; + page: number; + timeRange: TimeRange; +}) { + const { trackers, totalPages } = await getPaginatedThreadTrackers({ + userId, + type: ThreadTrackerType.AWAITING, + page, + timeRange, + }); + + return ( + + ); +} diff --git a/apps/web/app/(app)/reply-tracker/EnableReplyTracker.tsx b/apps/web/app/(app)/reply-tracker/EnableReplyTracker.tsx new file mode 100644 index 0000000000..efd4d2d293 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/EnableReplyTracker.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { EnableFeatureCard } from "@/components/EnableFeatureCard"; +import { toastSuccess } from "@/components/Toast"; +import { toastError } from "@/components/Toast"; +import { enableReplyTrackerAction } from "@/utils/actions/reply-tracking"; +import { isActionError } from "@/utils/error"; + +export function EnableReplyTracker() { + return ( + { + const result = await enableReplyTrackerAction(); + + if (isActionError(result)) { + toastError({ + title: "Error enabling reply tracker", + description: result.error, + }); + } else { + toastSuccess({ + title: "Reply tracker enabled", + description: "We've enabled reply tracking for you!", + }); + } + }} + /> + ); +} diff --git a/apps/web/app/(app)/reply-tracker/NeedsAction.tsx b/apps/web/app/(app)/reply-tracker/NeedsAction.tsx new file mode 100644 index 0000000000..b49cf14b92 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/NeedsAction.tsx @@ -0,0 +1,32 @@ +import { ThreadTrackerType } from "@prisma/client"; +import { ReplyTrackerEmails } from "@/app/(app)/reply-tracker/ReplyTrackerEmails"; +import { getPaginatedThreadTrackers } from "@/app/(app)/reply-tracker/fetch-trackers"; +import type { TimeRange } from "@/app/(app)/reply-tracker/date-filter"; + +export async function NeedsAction({ + userId, + userEmail, + page, + timeRange, +}: { + userId: string; + userEmail: string; + page: number; + timeRange: TimeRange; +}) { + const { trackers, totalPages } = await getPaginatedThreadTrackers({ + userId, + type: ThreadTrackerType.NEEDS_ACTION, + page, + timeRange, + }); + + return ( + + ); +} diff --git a/apps/web/app/(app)/reply-tracker/NeedsReply.tsx b/apps/web/app/(app)/reply-tracker/NeedsReply.tsx new file mode 100644 index 0000000000..75070d3283 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/NeedsReply.tsx @@ -0,0 +1,31 @@ +import { ThreadTrackerType } from "@prisma/client"; +import { ReplyTrackerEmails } from "@/app/(app)/reply-tracker/ReplyTrackerEmails"; +import { getPaginatedThreadTrackers } from "@/app/(app)/reply-tracker/fetch-trackers"; +import type { TimeRange } from "@/app/(app)/reply-tracker/date-filter"; +export async function NeedsReply({ + userId, + userEmail, + page, + timeRange, +}: { + userId: string; + userEmail: string; + page: number; + timeRange: TimeRange; +}) { + const { trackers, totalPages } = await getPaginatedThreadTrackers({ + userId, + type: ThreadTrackerType.NEEDS_REPLY, + page, + timeRange, + }); + + return ( + + ); +} diff --git a/apps/web/app/(app)/reply-tracker/ReplyTrackerEmails.tsx b/apps/web/app/(app)/reply-tracker/ReplyTrackerEmails.tsx new file mode 100644 index 0000000000..3318ddb262 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/ReplyTrackerEmails.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useState } from "react"; +import type { ParsedMessage } from "@/utils/types"; +import { type ThreadTracker, ThreadTrackerType } from "@prisma/client"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { EmailMessageCell } from "@/components/EmailMessageCell"; +import { Button } from "@/components/ui/button"; +import { CheckCircleIcon, HandIcon, MailIcon } from "lucide-react"; +import { useThreadsByIds } from "@/hooks/useThreadsByIds"; +import { resolveThreadTrackerAction } from "@/utils/actions/reply-tracking"; +import { isActionError } from "@/utils/error"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { useDisplayedEmail } from "@/hooks/useDisplayedEmail"; +import { Loading } from "@/components/Loading"; +import { TablePagination } from "@/components/TablePagination"; + +export function ReplyTrackerEmails({ + trackers, + userEmail, + type, + isResolved, + totalPages, +}: { + trackers: ThreadTracker[]; + userEmail: string; + type?: ThreadTrackerType; + isResolved?: boolean; + totalPages: number; +}) { + const { data, isLoading } = useThreadsByIds({ + threadIds: trackers.map((t) => t.threadId), + }); + + if (isLoading && !data) { + return ; + } + + if (!data?.threads.length) { + return ( +
+ +
+ ); + } + + return ( +
+ + + {data?.threads.map((thread) => ( + + ))} + +
+ + +
+ ); +} + +function Row({ + message, + userEmail, + isResolved, + type, +}: { + message: ParsedMessage; + userEmail: string; + isResolved?: boolean; + type?: ThreadTrackerType; +}) { + return ( + + +
+ +
+ {isResolved ? ( + + ) : ( + <> + {!!type && ( + + )} + + + )} +
+
+
+
+ ); +} + +function NudgeButton({ + threadId, + messageId, + type, +}: { + threadId: string; + messageId: string; + type: ThreadTrackerType; +}) { + const { showEmail } = useDisplayedEmail(); + + const showNudge = type === ThreadTrackerType.AWAITING; + + return ( + + ); +} + +function ResolveButton({ threadId }: { threadId: string }) { + const [isLoading, setIsLoading] = useState(false); + + return ( + + ); +} + +function UnresolveButton({ threadId }: { threadId: string }) { + const [isLoading, setIsLoading] = useState(false); + + return ( + + ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+
+

{message}

+
+
+ ); +} diff --git a/apps/web/app/(app)/reply-tracker/Resolved.tsx b/apps/web/app/(app)/reply-tracker/Resolved.tsx new file mode 100644 index 0000000000..bca9c760c5 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/Resolved.tsx @@ -0,0 +1,65 @@ +import prisma from "@/utils/prisma"; +import { ReplyTrackerEmails } from "@/app/(app)/reply-tracker/ReplyTrackerEmails"; +import { + getDateFilter, + type TimeRange, +} from "@/app/(app)/reply-tracker/date-filter"; +import { Prisma } from "@prisma/client"; + +const PAGE_SIZE = 20; + +export async function Resolved({ + userId, + userEmail, + page, + timeRange, +}: { + userId: string; + userEmail: string; + page: number; + timeRange: TimeRange; +}) { + const skip = (page - 1) * PAGE_SIZE; + const dateFilter = getDateFilter(timeRange); + + // Group by threadId and check if all resolved values are true + const [resolvedThreadTrackers, total] = await Promise.all([ + prisma.$queryRaw>` + SELECT MAX(id) as id + FROM "ThreadTracker" + WHERE "userId" = ${userId} + ${dateFilter ? Prisma.sql`AND "sentAt" <= (${dateFilter}->>'lte')::timestamp` : Prisma.empty} + GROUP BY "threadId" + HAVING bool_and(resolved) = true + ORDER BY MAX(id) DESC + LIMIT ${PAGE_SIZE} + OFFSET ${skip} + `, + prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(DISTINCT "threadId") as count + FROM "ThreadTracker" + WHERE "userId" = ${userId} + ${dateFilter ? Prisma.sql`AND "sentAt" <= (${dateFilter}->>'lte')::timestamp` : Prisma.empty} + GROUP BY "threadId" + HAVING bool_and(resolved) = true + `, + ]); + + const trackers = await prisma.threadTracker.findMany({ + where: { + id: { in: resolvedThreadTrackers.map((t) => t.id) }, + }, + orderBy: { createdAt: "desc" }, + }); + + const totalPages = Math.ceil(Number(total?.[0]?.count) / PAGE_SIZE); + + return ( + + ); +} diff --git a/apps/web/app/(app)/reply-tracker/TimeRangeFilter.tsx b/apps/web/app/(app)/reply-tracker/TimeRangeFilter.tsx new file mode 100644 index 0000000000..3f0288e317 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/TimeRangeFilter.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { TimeRange } from "@/app/(app)/reply-tracker/date-filter"; + +const timeRangeOptions = [ + { value: "all", label: "Show all" }, + { value: "3d", label: "3+ days old" }, + { value: "1w", label: "1+ week old" }, + { value: "2w", label: "2+ weeks old" }, + { value: "1m", label: "1+ month old" }, +] as const; + +export function TimeRangeFilter() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const timeRange = (searchParams.get("timeRange") as TimeRange) || "all"; + + const createQueryString = (value: TimeRange) => { + const params = new URLSearchParams(searchParams); + params.set("timeRange", value); + return params.toString(); + }; + + return ( + + ); +} diff --git a/apps/web/app/(app)/reply-tracker/date-filter.ts b/apps/web/app/(app)/reply-tracker/date-filter.ts new file mode 100644 index 0000000000..45a0de50b3 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/date-filter.ts @@ -0,0 +1,22 @@ +export type TimeRange = "all" | "3d" | "1w" | "2w" | "1m"; + +export function getDateFilter(timeRange: TimeRange) { + if (timeRange === "all") return undefined; + + const now = new Date(); + switch (timeRange) { + case "3d": + now.setDate(now.getDate() - 3); + break; + case "1w": + now.setDate(now.getDate() - 7); + break; + case "2w": + now.setDate(now.getDate() - 14); + break; + case "1m": + now.setMonth(now.getMonth() - 1); + break; + } + return { lte: now }; +} diff --git a/apps/web/app/(app)/reply-tracker/fetch-trackers.ts b/apps/web/app/(app)/reply-tracker/fetch-trackers.ts new file mode 100644 index 0000000000..ad1d18ab42 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/fetch-trackers.ts @@ -0,0 +1,60 @@ +import prisma from "@/utils/prisma"; +import type { ThreadTrackerType } from "@prisma/client"; +import { + getDateFilter, + type TimeRange, +} from "@/app/(app)/reply-tracker/date-filter"; + +const PAGE_SIZE = 20; + +export async function getPaginatedThreadTrackers({ + userId, + type, + page, + timeRange = "all", +}: { + userId: string; + type: ThreadTrackerType; + page: number; + timeRange?: TimeRange; +}) { + const skip = (page - 1) * PAGE_SIZE; + const dateFilter = getDateFilter(timeRange); + + const [trackers, total] = await Promise.all([ + prisma.threadTracker.findMany({ + where: { + userId, + resolved: false, + type, + sentAt: dateFilter, + }, + orderBy: { + createdAt: "desc", + }, + distinct: ["threadId"], + take: PAGE_SIZE, + skip, + }), + dateFilter + ? prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(DISTINCT "threadId") as count + FROM "ThreadTracker" + WHERE "userId" = ${userId} + AND "resolved" = false + AND "type" = ${type}::text::"ThreadTrackerType" + AND "sentAt" <= ${dateFilter.lte} + ` + : prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(DISTINCT "threadId") as count + FROM "ThreadTracker" + WHERE "userId" = ${userId} + AND "resolved" = false + AND "type" = ${type}::text::"ThreadTrackerType" + `, + ]); + + const totalPages = Math.ceil(Number(total?.[0]?.count) / PAGE_SIZE); + + return { trackers, totalPages }; +} diff --git a/apps/web/app/(app)/reply-tracker/page.tsx b/apps/web/app/(app)/reply-tracker/page.tsx new file mode 100644 index 0000000000..fc5591a0a7 --- /dev/null +++ b/apps/web/app/(app)/reply-tracker/page.tsx @@ -0,0 +1,106 @@ +import { redirect } from "next/navigation"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CheckCircleIcon, ClockIcon, MailIcon } from "lucide-react"; +import { NeedsReply } from "./NeedsReply"; +import { Resolved } from "@/app/(app)/reply-tracker/Resolved"; +import { AwaitingReply } from "@/app/(app)/reply-tracker/AwaitingReply"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import prisma from "@/utils/prisma"; +import { EnableReplyTracker } from "@/app/(app)/reply-tracker/EnableReplyTracker"; +import { TimeRangeFilter } from "./TimeRangeFilter"; +import type { TimeRange } from "@/app/(app)/reply-tracker/date-filter"; + +export default async function ReplyTrackerPage({ + searchParams, +}: { + searchParams: { page?: string; timeRange?: TimeRange }; +}) { + const session = await auth(); + if (!session?.user.email) redirect("/login"); + + const userId = session.user.id; + const userEmail = session.user.email; + + const trackRepliesRule = await prisma.rule.findFirst({ + where: { userId, trackReplies: true }, + select: { trackReplies: true }, + }); + + if (!trackRepliesRule?.trackReplies) { + return ; + } + + const page = Number(searchParams.page || "1"); + const timeRange = searchParams.timeRange || "all"; + + return ( + +
+
+
+ + + + To Reply + + + + Waiting + + {/* + + Needs Action + */} + + + + Resolved + + + +
+
+
+ + + + + + + + + + {/* + + */} + + + + +
+ ); +} diff --git a/apps/web/app/(app)/stats/LargestEmails.tsx b/apps/web/app/(app)/stats/LargestEmails.tsx index e026540e3d..9dfe2da116 100644 --- a/apps/web/app/(app)/stats/LargestEmails.tsx +++ b/apps/web/app/(app)/stats/LargestEmails.tsx @@ -13,7 +13,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import type { LargestEmailsResponse } from "@/app/api/user/stats/largest-emails/route"; import { useExpanded } from "@/app/(app)/stats/useExpanded"; import { bytesToMegabytes } from "@/utils/size"; -import { formatShortDate } from "@/utils/date"; +import { formatShortDate, internalDateToDate } from "@/utils/date"; import { getGmailUrl } from "@/utils/url"; import { Button } from "@/components/ui/button"; import { ButtonLoader } from "@/components/Loading"; @@ -82,10 +82,13 @@ export function LargestEmails(props: { refreshInterval: number }) { {truncate(item.headers.subject, { length: 80 })} - {formatShortDate(new Date(+(item.internalDate || 0)), { - includeYear: true, - lowercase: true, - })} + {formatShortDate( + internalDateToDate(item.internalDate), + { + includeYear: true, + lowercase: true, + }, + )} {bytesToMegabytes(totalAttachmentSize).toFixed(1)} MB diff --git a/apps/web/app/api/ai/summarise/route.ts b/apps/web/app/api/ai/summarise/route.ts index 7c74e749be..d140687ed4 100644 --- a/apps/web/app/api/ai/summarise/route.ts +++ b/apps/web/app/api/ai/summarise/route.ts @@ -3,8 +3,7 @@ import { summarise } from "@/app/api/ai/summarise/controller"; import { withError } from "@/utils/middleware"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { summariseBody } from "@/app/api/ai/summarise/validation"; -import { getSummary, saveSummary } from "@/utils/redis/summary"; -import { expire } from "@/utils/redis"; +import { getSummary } from "@/utils/redis/summary"; import { emailToContent } from "@/utils/mail"; import prisma from "@/utils/prisma"; diff --git a/apps/web/app/api/google/threads/batch/route.ts b/apps/web/app/api/google/threads/batch/route.ts new file mode 100644 index 0000000000..d90622db11 --- /dev/null +++ b/apps/web/app/api/google/threads/batch/route.ts @@ -0,0 +1,58 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { z } from "zod"; +import { withError } from "@/utils/middleware"; +import { getThreadsBatch } from "@/utils/gmail/thread"; +import { parseMessages } from "@/utils/mail"; +import { isDefined, type ThreadWithPayloadMessages } from "@/utils/types"; + +const requestSchema = z.object({ threadIds: z.array(z.string()) }); + +export type ThreadsBatchResponse = Awaited>; + +async function getThreads(threadIds: string[], accessToken: string) { + const threads = await getThreadsBatch(threadIds, accessToken); + + const threadsWithMessages = await Promise.all( + threads.map(async (thread) => { + const id = thread.id; + if (!id) return; + const messages = parseMessages(thread as ThreadWithPayloadMessages); + + return { + id, + messages, + }; + }) || [], + ); + + return { + threads: threadsWithMessages.filter(isDefined), + }; +} + +export const GET = withError(async (request: NextRequest) => { + const session = await auth(); + if (!session?.user) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const { threadIds } = requestSchema.parse({ + threadIds: searchParams.get("threadIds")?.split(",") || [], + }); + + if (threadIds.length === 0) { + return NextResponse.json({ threads: [] } satisfies ThreadsBatchResponse); + } + + const accessToken = session.accessToken; + if (!accessToken) + return NextResponse.json( + { error: "Missing access token" }, + { status: 401 }, + ); + + const response = await getThreads(threadIds, accessToken); + + return NextResponse.json(response); +}); diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index 6fa09ef66a..5ba6155599 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -24,6 +24,7 @@ import { createScopedLogger } from "@/utils/logger"; import { markMessageAsProcessing } from "@/utils/redis/message-processing"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; import { processAssistantEmail } from "@/utils/assistant/process-assistant-email"; +import { handleOutboundReply } from "@/utils/reply-tracker/outbound"; const logger = createScopedLogger("Process History"); @@ -85,7 +86,10 @@ export async function processHistoryForUser( : undefined; if (!premium) { - logger.info("Account not premium", { email }); + logger.info("Account not premium", { + email, + lemonSqueezyRenewsAt: account.user.premium?.lemonSqueezyRenewsAt, + }); await unwatchEmails(account); return NextResponse.json({ ok: true }); } @@ -391,8 +395,14 @@ async function processHistoryItem( return; } - // skip SENT emails that are not assistant emails - if (message.labelIds?.includes(GmailLabel.SENT)) return; + const isOutbound = message.labelIds?.includes(GmailLabel.SENT); + + if (isOutbound) { + await handleOutboundReply(user, message, gmail); + } + + // skip outbound emails + if (isOutbound) return; const blocked = await blockUnsubscribedEmails({ from: message.headers.from, @@ -487,9 +497,12 @@ async function processHistoryItem( isTest: false, }); } - } catch (error: any) { + } catch (error: unknown) { // gmail bug or snoozed email: https://stackoverflow.com/questions/65290987/gmail-api-getmessage-method-returns-404-for-message-gotten-from-listhistory-meth - if (error.message === "Requested entity was not found.") { + if ( + error instanceof Error && + error.message === "Requested entity was not found." + ) { logger.info("Message not found", { email: userEmail, messageId, diff --git a/apps/web/app/api/resend/summary/route.ts b/apps/web/app/api/resend/summary/route.ts index 71c5b6c157..4d5c5850e0 100644 --- a/apps/web/app/api/resend/summary/route.ts +++ b/apps/web/app/api/resend/summary/route.ts @@ -9,10 +9,16 @@ import { captureException } from "@/utils/error"; import prisma from "@/utils/prisma"; import { ExecutedRuleStatus } from "@prisma/client"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { ThreadTrackerType } from "@prisma/client"; +import { createScopedLogger } from "@/utils/logger"; +import { getMessagesBatch } from "@/utils/gmail/message"; +import { decodeSnippet } from "@/utils/gmail/decode"; + +const logger = createScopedLogger("resend/summary"); const sendSummaryEmailBody = z.object({ email: z.string() }); -async function sendEmail({ email }: { email: string }) { +async function sendEmail({ email, force }: { email: string; force?: boolean }) { // run every 7 days. but overlap by 1 hour const days = 7; const cutOffDate = subHours(new Date(), days * 24 + 1); @@ -20,12 +26,17 @@ async function sendEmail({ email }: { email: string }) { const user = await prisma.user.findUnique({ where: { email, - OR: [ - { lastSummaryEmailAt: { lt: cutOffDate } }, - { lastSummaryEmailAt: null }, - ], + ...(force + ? {} + : { + OR: [ + { lastSummaryEmailAt: { lt: cutOffDate } }, + { lastSummaryEmailAt: null }, + ], + }), }, select: { + id: true, coldEmails: { where: { createdAt: { gt: cutOffDate } } }, _count: { select: { @@ -37,17 +48,132 @@ async function sendEmail({ email }: { email: string }) { }, }, }, + accounts: { + select: { + access_token: true, + }, + }, }, }); if (!user) return { success: false }; + // Get counts and recent threads for each type + const [counts, needsReply, awaitingReply, needsAction] = await Promise.all([ + // NOTE: should really be distinct by threadId. this will cause a mismatch in some cases + prisma.threadTracker.groupBy({ + by: ["type"], + where: { + userId: user.id, + resolved: false, + sentAt: { gt: cutOffDate }, + }, + _count: true, + }), + prisma.threadTracker.findMany({ + where: { + userId: user.id, + type: ThreadTrackerType.NEEDS_REPLY, + resolved: false, + sentAt: { gt: cutOffDate }, + }, + orderBy: { sentAt: "desc" }, + take: 5, + distinct: ["threadId"], + }), + prisma.threadTracker.findMany({ + where: { + userId: user.id, + type: ThreadTrackerType.AWAITING, + resolved: false, + sentAt: { gt: cutOffDate }, + }, + orderBy: { sentAt: "desc" }, + take: 5, + distinct: ["threadId"], + }), + prisma.threadTracker.findMany({ + where: { + userId: user.id, + type: ThreadTrackerType.NEEDS_ACTION, + resolved: false, + sentAt: { gt: cutOffDate }, + }, + orderBy: { sentAt: "desc" }, + take: 5, + distinct: ["threadId"], + }), + ]); + + const typeCounts = Object.fromEntries( + counts.map((count) => [count.type, count._count]), + ); + const coldEmailers = user.coldEmails.map((e) => ({ from: e.fromEmail, subject: "", + sentAt: e.createdAt, })); const pendingCount = user._count.executedRules; - const shouldSendEmail = coldEmailers.length && pendingCount; + + // get messages + const messageIds = [ + ...needsReply.map((m) => m.messageId), + ...awaitingReply.map((m) => m.messageId), + ...needsAction.map((m) => m.messageId), + ]; + + const messages = user.accounts?.[0]?.access_token + ? await getMessagesBatch(messageIds, user.accounts[0].access_token) + : []; + + const messageMap = Object.fromEntries( + messages.map((message) => [message.id, message]), + ); + + const recentNeedsReply = needsReply.map((t) => { + const message = messageMap[t.messageId]; + return { + from: message?.headers.from || "Unknown", + subject: decodeSnippet(message?.snippet) || "", + sentAt: t.sentAt, + }; + }); + + const recentAwaitingReply = awaitingReply.map((t) => { + const message = messageMap[t.messageId]; + return { + from: message?.headers.to || "Unknown", + subject: decodeSnippet(message?.snippet) || "", + sentAt: t.sentAt, + }; + }); + + const recentNeedsAction = needsAction.map((t) => { + const message = messageMap[t.messageId]; + return { + from: message?.headers.from || "Unknown", + subject: decodeSnippet(message?.snippet) || "", + sentAt: t.sentAt, + }; + }); + + const shouldSendEmail = !!( + coldEmailers.length || + pendingCount || + typeCounts[ThreadTrackerType.NEEDS_REPLY] || + typeCounts[ThreadTrackerType.AWAITING] || + typeCounts[ThreadTrackerType.NEEDS_ACTION] + ); + + logger.info("Sending summary email to user", { + shouldSendEmail, + coldEmailers: coldEmailers.length, + pendingCount, + needsReplyCount: typeCounts[ThreadTrackerType.NEEDS_REPLY], + awaitingReplyCount: typeCounts[ThreadTrackerType.AWAITING], + needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION], + }); await Promise.all([ shouldSendEmail @@ -57,6 +183,12 @@ async function sendEmail({ email }: { email: string }) { baseUrl: env.NEXT_PUBLIC_BASE_URL, coldEmailers, pendingCount, + needsReplyCount: typeCounts[ThreadTrackerType.NEEDS_REPLY], + awaitingReplyCount: typeCounts[ThreadTrackerType.AWAITING], + needsActionCount: typeCounts[ThreadTrackerType.NEEDS_ACTION], + needsReply: recentNeedsReply, + awaitingReply: recentAwaitingReply, + needsAction: recentNeedsAction, }, }) : async () => {}, @@ -76,18 +208,22 @@ export const GET = withError(async () => { const email = session?.user.email; if (!email) return NextResponse.json({ error: "Not authenticated" }); - const result = await sendEmail({ email }); + logger.info("Sending summary email to user GET", { email }); + + const result = await sendEmail({ email, force: true }); return NextResponse.json(result); }); export const POST = withError(async (request: Request) => { - console.log("sending summary email to user"); if (!hasCronSecret(request)) { + logger.error("Unauthorized cron request"); captureException(new Error("Unauthorized cron request: resend")); return new Response("Unauthorized", { status: 401 }); } + logger.info("Sending summary email to user POST"); + const json = await request.json(); const body = sendSummaryEmailBody.parse(json); diff --git a/apps/web/app/api/user/complete-registration/route.ts b/apps/web/app/api/user/complete-registration/route.ts index 27be57bd2a..5f4f90b777 100644 --- a/apps/web/app/api/user/complete-registration/route.ts +++ b/apps/web/app/api/user/complete-registration/route.ts @@ -6,6 +6,9 @@ import { sendCompleteRegistrationEvent } from "@/utils/fb"; import { posthogCaptureEvent } from "@/utils/posthog"; import prisma from "@/utils/prisma"; import { ONE_HOUR_MS } from "@/utils/date"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("complete-registration"); export type CompleteRegistrationBody = Record; @@ -37,7 +40,24 @@ export const POST = withError(async (_request: NextRequest) => { session.user.email, ); - await Promise.allSettled([fbPromise, posthogPromise]); + const [fbResult, posthogResult] = await Promise.allSettled([ + fbPromise, + posthogPromise, + ]); + + if (fbResult.status === "rejected") { + logger.error("Facebook tracking failed", { + error: fbResult.reason, + email: session.user.email, + }); + } + + if (posthogResult.status === "rejected") { + logger.error("Posthog tracking failed", { + error: posthogResult.reason, + email: session.user.email, + }); + } return NextResponse.json({ success: true }); }); diff --git a/apps/web/components/EmailViewer.tsx b/apps/web/components/EmailViewer.tsx index 003d57a2f5..f5c09912d5 100644 --- a/apps/web/components/EmailViewer.tsx +++ b/apps/web/components/EmailViewer.tsx @@ -1,5 +1,6 @@ "use client"; +import { useSession } from "next-auth/react"; import { useCallback } from "react"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { useDisplayedEmail } from "@/hooks/useDisplayedEmail"; @@ -9,10 +10,13 @@ import { LoadingContent } from "@/components/LoadingContent"; import { ErrorBoundary } from "@/components/ErrorBoundary"; export function EmailViewer() { - const { threadId, showEmail } = useDisplayedEmail(); + const { threadId, showEmail, showReplyButton, autoOpenReplyForMessageId } = + useDisplayedEmail(); const hideEmail = useCallback(() => showEmail(null), [showEmail]); + const { data } = useSession(); + return ( - {threadId && } + {threadId && ( + + )} ); } -function EmailContent({ threadId }: { threadId: string }) { +function EmailContent({ + threadId, + showReplyButton, + autoOpenReplyForMessageId, + userEmail, +}: { + threadId: string; + showReplyButton: boolean; + autoOpenReplyForMessageId?: string; + userEmail: string; +}) { const { data, isLoading, error, mutate } = useThread({ id: threadId }); return ( @@ -37,7 +58,9 @@ function EmailContent({ threadId }: { threadId: string }) { )} diff --git a/apps/web/components/EnableFeatureCard.tsx b/apps/web/components/EnableFeatureCard.tsx new file mode 100644 index 0000000000..abd2667136 --- /dev/null +++ b/apps/web/components/EnableFeatureCard.tsx @@ -0,0 +1,53 @@ +"use client"; + +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { SectionDescription, TypographyH3 } from "@/components/Typography"; +import Link from "next/link"; + +interface EnableFeatureCardProps { + title: string; + description: string; + imageSrc: string; + imageAlt: string; + buttonText: string; + href?: string; + onEnable?: () => void; +} + +export function EnableFeatureCard({ + title, + description, + imageSrc, + imageAlt, + buttonText, + href, + onEnable, +}: EnableFeatureCardProps) { + return ( + +
+ {imageAlt} + + {title} + {description} +
+ {href ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index aee21292ba..6fd03f57d9 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -22,6 +22,7 @@ import { ListCheckIcon, type LucideIcon, MailsIcon, + MessageCircleReplyIcon, MessagesSquareIcon, PenIcon, PersonStandingIcon, @@ -37,7 +38,7 @@ import { Logo } from "@/components/Logo"; import { Button } from "@/components/ui/button"; import { useComposeModal } from "@/providers/ComposeModalProvider"; import { env } from "@/env"; -import { useSmartCategoriesEnabled } from "@/hooks/useFeatureFlags"; +import { useReplyTrackingEnabled } from "@/hooks/useFeatureFlags"; type NavItem = { name: string; @@ -57,9 +58,9 @@ const navigationItems: NavItem[] = [ icon: SparklesIcon, }, { - name: "Smart Categories", - href: "/smart-categories", - icon: TagIcon, + name: "Reply Tracker", + href: "/reply-tracker", + icon: MessageCircleReplyIcon, }, ...(NEXT_PUBLIC_DISABLE_TINYBIRD ? [] @@ -75,6 +76,11 @@ const navigationItems: NavItem[] = [ href: "/cold-email-blocker", icon: ShieldCheckIcon, }, + { + name: "Smart Categories", + href: "/smart-categories", + icon: TagIcon, + }, ...(NEXT_PUBLIC_DISABLE_TINYBIRD ? [] : [ @@ -87,11 +93,12 @@ const navigationItems: NavItem[] = [ ]; export const useNavigation = () => { - const showSmartCategories = useSmartCategoriesEnabled(); + const showReplyTracker = useReplyTrackingEnabled(); - return navigationItems.filter((item) => - item.href === "/smart-categories" ? showSmartCategories : true, - ); + return navigationItems.filter((item) => { + if (item.href === "/reply-tracker") return showReplyTracker; + return true; + }); }; const bottomLinks: NavItem[] = [ diff --git a/apps/web/components/Tiptap.tsx b/apps/web/components/Tiptap.tsx new file mode 100644 index 0000000000..3038f83636 --- /dev/null +++ b/apps/web/components/Tiptap.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEditor, EditorContent, type Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { useCallback } from "react"; +import { cn } from "@/utils"; +import "@/styles/prosemirror.css"; + +export function Tiptap({ + initialContent = "", + onChange, + className, + autofocus = true, +}: { + initialContent?: string; + onChange?: (html: string) => void; + className?: string; + autofocus?: boolean; +}) { + const editor = useEditor({ + extensions: [StarterKit as any], + content: initialContent, + onUpdate: useCallback( + ({ editor }: { editor: Editor }) => { + const html = editor.getHTML(); + onChange?.(html); + }, + [onChange], + ), + autofocus, + editorProps: { + attributes: { + class: cn( + "prose prose-sm sm:prose-base max-w-none focus:outline-none min-h-[150px] px-3 py-2", + className, + ), + }, + }, + }); + + return ( +
+ +
+ ); +} diff --git a/apps/web/components/Toggle.tsx b/apps/web/components/Toggle.tsx index 9869e22a85..b74d5f0150 100644 --- a/apps/web/components/Toggle.tsx +++ b/apps/web/components/Toggle.tsx @@ -2,33 +2,44 @@ import { Switch, Field } from "@headlessui/react"; import clsx from "clsx"; import type { FieldError } from "react-hook-form"; import { ErrorMessage, ExplainText, Label } from "./Input"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; export interface ToggleProps { name: string; label?: string; labelRight?: string; + tooltipText?: string; enabled: boolean; explainText?: string; error?: FieldError; onChange: (enabled: boolean) => void; + bgClass?: string; } export const Toggle = (props: ToggleProps) => { - const { label, labelRight, enabled, onChange } = props; + const { + label, + labelRight, + tooltipText, + enabled, + onChange, + bgClass = "bg-black", + } = props; return (
{label && ( - + )} @@ -42,8 +53,9 @@ export const Toggle = (props: ToggleProps) => { /> {labelRight && ( - + )} diff --git a/apps/web/components/email-list/EmailList.tsx b/apps/web/components/email-list/EmailList.tsx index f670c823dc..034ae5d859 100644 --- a/apps/web/components/email-list/EmailList.tsx +++ b/apps/web/components/email-list/EmailList.tsx @@ -547,6 +547,7 @@ export function EmailList({ !!(openThreadId && openedRow) && (
diff --git a/apps/web/components/email-list/EmailPanel.tsx b/apps/web/components/email-list/EmailPanel.tsx index bb815b88f2..f52492174d 100644 --- a/apps/web/components/email-list/EmailPanel.tsx +++ b/apps/web/components/email-list/EmailPanel.tsx @@ -1,10 +1,21 @@ -import { type SyntheticEvent, useCallback, useMemo, useState } from "react"; +import { + type SyntheticEvent, + useCallback, + useMemo, + useState, + useRef, + useEffect, +} from "react"; import Link from "next/link"; import { DownloadIcon, ForwardIcon, ReplyIcon, XIcon } from "lucide-react"; import { ActionButtons } from "@/components/ActionButtons"; import { Tooltip } from "@/components/Tooltip"; import type { Thread } from "@/components/email-list/types"; -import { extractNameFromEmail } from "@/utils/email"; +import { + extractEmailAddress, + extractNameFromEmail, + normalizeEmailAddress, +} from "@/utils/email"; import { formatShortDate } from "@/utils/date"; import { ComposeEmailFormLazy } from "@/app/(app)/compose/ComposeEmailFormLazy"; import { Button } from "@/components/ui/button"; @@ -18,9 +29,11 @@ import { forwardEmailText, } from "@/utils/gmail/forward"; import { useIsInAiQueue } from "@/store/ai-queue"; +import { Loading } from "@/components/Loading"; export function EmailPanel({ row, + userEmail, isCategorizing, onPlanAiAction, onAiCategorize, @@ -33,6 +46,7 @@ export function EmailPanel({ refetch, }: { row: Thread; + userEmail: string; isCategorizing: boolean; onPlanAiAction: (thread: Thread) => void; onAiCategorize: (thread: Thread) => void; @@ -101,6 +115,7 @@ export function EmailPanel({ messages={row.messages} refetch={refetch} showReplyButton + userEmail={userEmail} />
@@ -111,10 +126,14 @@ export function EmailThread({ messages, refetch, showReplyButton, + autoOpenReplyForMessageId, + userEmail, }: { messages: Thread["messages"]; refetch: () => void; showReplyButton: boolean; + autoOpenReplyForMessageId?: string; + userEmail: string; }) { return (
@@ -125,6 +144,8 @@ export function EmailThread({ message={message} showReplyButton={showReplyButton} refetch={refetch} + defaultShowReply={autoOpenReplyForMessageId === message.id} + userEmail={userEmail} /> ))} @@ -136,12 +157,26 @@ function EmailMessage({ message, refetch, showReplyButton, + defaultShowReply, + userEmail, }: { message: Thread["messages"][0]; refetch: () => void; showReplyButton: boolean; + defaultShowReply?: boolean; + userEmail: string; }) { - const [showReply, setShowReply] = useState(false); + const [showReply, setShowReply] = useState(defaultShowReply || false); + const replyRef = useRef(null); + + useEffect(() => { + if (defaultShowReply && replyRef.current) { + setTimeout(() => { + replyRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 300); + } + }, [defaultShowReply]); + const onReply = useCallback(() => setShowReply(true), []); const [showForward, setShowForward] = useState(false); const onForward = useCallback(() => setShowForward(true), []); @@ -151,16 +186,37 @@ function EmailMessage({ setShowForward(false); }, []); - const prepareReplyingToEmail = (message: ParsedMessage) => ({ - to: message.headers.from, - subject: `Re: ${message.headers.subject}`, - headerMessageId: message.headers["message-id"]!, - threadId: message.threadId!, - cc: message.headers.cc, - references: message.headers.references, - messageText: "", - messageHtml: "", - }); + const prepareReplyingToEmail = (message: ParsedMessage) => { + const normalizedFrom = normalizeEmailAddress( + extractEmailAddress(message.headers.from), + ); + const normalizedUserEmail = normalizeEmailAddress(userEmail); + const sentFromUser = normalizedFrom === normalizedUserEmail; + console.log( + "🚀 ~ prepareReplyingToEmail ~ sentFromUser:", + sentFromUser, + normalizedFrom, + normalizedUserEmail, + ); + + return { + // If following an email from yourself, use original recipients, otherwise reply to sender + to: sentFromUser ? message.headers.to : message.headers.from, + // If following an email from yourself, don't add "Re:" prefix + subject: sentFromUser + ? message.headers.subject + : `Re: ${message.headers.subject}`, + headerMessageId: message.headers["message-id"]!, + threadId: message.threadId!, + // Keep original CC + cc: message.headers.cc, + // Keep original BCC if available + bcc: sentFromUser ? message.headers.bcc : "", + references: message.headers.references, + messageText: "", + messageHtml: "", + }; + }; const prepareForwardingEmail = (message: ParsedMessage) => ({ to: "", @@ -173,6 +229,10 @@ function EmailMessage({ messageHtml: forwardEmailHtml({ content: "", message }), }); + const replyingToEmail = showReply + ? prepareReplyingToEmail(message) + : prepareForwardingEmail(message); + return (
  • @@ -245,14 +305,9 @@ function EmailMessage({ <> -
    +
    getIframeHtml(html), [html]); + const [isLoading, setIsLoading] = useState(true); const onLoad = useCallback( (event: SyntheticEvent) => { @@ -273,22 +329,28 @@ export function HtmlEmail({ html }: { html: string }) { // sometimes we see minimal scrollbar, so add a buffer const BUFFER = 5; - event.currentTarget.style.height = `${ + const height = `${ event.currentTarget.contentWindow.document.documentElement .scrollHeight + BUFFER }px`; + + event.currentTarget.style.height = height; + setIsLoading(false); } }, [], ); return ( -