From ca8e7098b1bf2dde58e3c3da0146db505a12609b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:05:46 -0500 Subject: [PATCH 1/4] Allow 2-way sort --- .../BulkUnsubscribeDesktop.tsx | 21 +++++++++++++---- .../BulkUnsubscribeSection.tsx | 19 ++++++++++++++- .../bulk-unsubscribe/common.tsx | 9 ++++++-- .../app/api/user/stats/newsletters/route.ts | 23 ++++++++++++------- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index d50407d5d1..32aa30002d 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -25,13 +25,15 @@ import { export function BulkUnsubscribeDesktop({ tableRows, sortColumn, - setSortColumn, + sortDirection, + onSort, isAllSelected, onToggleSelectAll, }: { tableRows?: React.ReactNode; sortColumn: "emails" | "unread" | "unarchived"; - setSortColumn: (sortColumn: "emails" | "unread" | "unarchived") => void; + sortDirection: "asc" | "desc"; + onSort: (column: "emails" | "unread" | "unarchived") => void; isAllSelected: boolean; onToggleSelectAll: () => void; }) { @@ -48,7 +50,10 @@ export function BulkUnsubscribeDesktop({ setSortColumn("emails")} + sortDirection={ + sortColumn === "emails" ? sortDirection : undefined + } + onClick={() => onSort("emails")} > Emails @@ -56,7 +61,10 @@ export function BulkUnsubscribeDesktop({ setSortColumn("unread")} + sortDirection={ + sortColumn === "unread" ? sortDirection : undefined + } + onClick={() => onSort("unread")} > Read @@ -64,7 +72,10 @@ export function BulkUnsubscribeDesktop({ setSortColumn("unarchived")} + sortDirection={ + sortColumn === "unarchived" ? sortDirection : undefined + } + onClick={() => onSort("unarchived")} > Archived diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index 354fab81e9..9d343342f8 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -104,6 +104,21 @@ export function BulkUnsubscribe() { const [sortColumn, setSortColumn] = useState< "emails" | "unread" | "unarchived" >("emails"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + + const handleSort = useCallback( + (column: "emails" | "unread" | "unarchived") => { + if (sortColumn === column) { + // Toggle direction if clicking the same column + setSortDirection((prev) => (prev === "desc" ? "asc" : "desc")); + } else { + // Set new column with default desc direction + setSortColumn(column); + setSortDirection("desc"); + } + }, + [sortColumn], + ); const { typesArray } = useEmailsToIncludeFilter(); const { filtersArray, filters, setFilters } = useNewsletterFilter(); @@ -117,6 +132,7 @@ export function BulkUnsubscribe() { types: typesArray, filters: filtersArray, orderBy: sortColumn, + orderDirection: sortDirection, limit: expanded ? 500 : 50, includeMissingUnsubscribe: true, ...getDateRangeParams(dateRange), @@ -370,7 +386,8 @@ export function BulkUnsubscribe() { ) : ( ({ export function HeaderButton(props: { children: React.ReactNode; sorted: boolean; + sortDirection?: "asc" | "desc"; onClick: () => void; }) { return ( @@ -521,7 +522,11 @@ export function HeaderButton(props: { > {props.children} {props.sorted ? ( - + props.sortDirection === "asc" ? ( + + ) : ( + + ) ) : ( )} diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index 980d621ab5..8983e4eaee 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -18,6 +18,7 @@ const newsletterStatsQuery = z.object({ fromDate: z.coerce.number().nullish(), toDate: z.coerce.number().nullish(), orderBy: z.enum(["emails", "unread", "unarchived"]).optional(), + orderDirection: z.enum(["asc", "desc"]).optional(), types: z .array(z.enum(["read", "unread", "archived", "unarchived", ""])) .transform((arr) => arr?.filter(Boolean)), @@ -195,7 +196,7 @@ async function getNewsletterCounts( // Build order by clause (safe, no user input) const orderByClause = options.orderBy - ? getOrderByClause(options.orderBy) + ? getOrderByClause(options.orderBy, options.orderDirection) : '"count" DESC'; // Build limit clause (safe, validated number) @@ -242,18 +243,23 @@ async function getNewsletterCounts( } } -function getOrderByClause(orderBy: string): string { +function getOrderByClause( + orderBy: string, + orderDirection?: "asc" | "desc", +): string { + const direction = orderDirection?.toUpperCase() || "DESC"; + switch (orderBy) { case "emails": - return '"count" DESC'; + return `"count" ${direction}`; case "unread": - // Sort by read percentage ascending (lowest read % first = most unread) - return '"readEmails"::float / NULLIF("count", 0) ASC'; + // Sort by read percentage (lower = more unread) + return `"readEmails"::float / NULLIF("count", 0) ${direction}`; case "unarchived": - // Sort by archived percentage ascending (lowest archived % first = most in inbox) - return '("count" - "inboxEmails")::float / NULLIF("count", 0) ASC'; + // Sort by archived percentage (lower = more in inbox) + return `("count" - "inboxEmails")::float / NULLIF("count", 0) ${direction}`; default: - return '"count" DESC'; + return `"count" ${direction}`; } } @@ -269,6 +275,7 @@ export const GET = withEmailProvider( fromDate: searchParams.get("fromDate"), toDate: searchParams.get("toDate"), orderBy: searchParams.get("orderBy"), + orderDirection: searchParams.get("orderDirection") || undefined, types: searchParams.get("types")?.split(",") || [], filters: searchParams.get("filters")?.split(",") || [], includeMissingUnsubscribe: From 91ad9e6f70e2aaa5a6a46fad209b9d807102789f Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:40:08 -0500 Subject: [PATCH 2/4] simplify bulk unsub sorting --- .../BulkUnsubscribeSection.tsx | 209 +++++++++--------- .../bulk-unsubscribe/SearchBar.tsx | 2 +- .../bulk-unsubscribe/hooks.ts | 40 ++-- .../[emailAccountId]/stats/useExpanded.tsx | 17 +- .../app/api/user/stats/newsletters/route.ts | 4 +- apps/web/prisma.config.ts | 1 + .../migration.sql | 2 + apps/web/prisma/schema.prisma | 1 + 8 files changed, 153 insertions(+), 123 deletions(-) create mode 100644 apps/web/prisma/migrations/20251204222441_fromname_index/migration.sql diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index 9d343342f8..58bae7abba 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -3,8 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import useSWR from "swr"; import { subDays } from "date-fns/subDays"; +import { ChevronDown } from "lucide-react"; import { usePostHog } from "posthog-js/react"; -import { ArchiveIcon, FilterIcon } from "lucide-react"; +import { + ArchiveIcon, + BadgeCheckIcon, + CheckIcon, + ChevronsDownIcon, + ChevronsUpIcon, + InboxIcon, + ListIcon, + MailMinusIcon, +} from "lucide-react"; import type { DateRange } from "react-day-picker"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; @@ -12,15 +22,14 @@ import type { NewsletterStatsQuery, NewsletterStatsResponse, } from "@/app/api/user/stats/newsletters/route"; -import { useExpanded } from "@/app/(app)/[emailAccountId]/stats/useExpanded"; import { getDateRangeParams } from "@/app/(app)/[emailAccountId]/stats/params"; import { NewsletterModal } from "@/app/(app)/[emailAccountId]/stats/NewsletterModal"; import { useEmailsToIncludeFilter } from "@/app/(app)/[emailAccountId]/stats/EmailsToIncludeFilter"; -import { DetailedStatsFilter } from "@/app/(app)/[emailAccountId]/stats/DetailedStatsFilter"; import { usePremium } from "@/components/PremiumAlert"; import { useNewsletterFilter, useBulkUnsubscribeShortcuts, + type NewsletterFilterType, } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks"; import { useStatLoader } from "@/providers/StatLoaderProvider"; import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; @@ -39,7 +48,6 @@ import { useToggleSelect } from "@/hooks/useToggleSelect"; import { BulkActions } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions"; import { ArchiveProgress } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ArchiveProgress"; import { ClientOnly } from "@/components/ClientOnly"; -import { Toggle } from "@/components/Toggle"; import { useAccount } from "@/providers/EmailAccountProvider"; import { useWindowSize } from "usehooks-ts"; import { LoadStatsButton } from "@/app/(app)/[emailAccountId]/stats/LoadStatsButton"; @@ -49,6 +57,46 @@ import { TextLink } from "@/components/Typography"; import { DismissibleVideoCard } from "@/components/VideoCard"; import { ActionBar } from "@/app/(app)/[emailAccountId]/stats/ActionBar"; import { DatePickerWithRange } from "@/components/DatePickerWithRange"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +type Newsletter = NewsletterStatsResponse["newsletters"][number]; + +const filterOptions: { + label: string; + value: NewsletterFilterType; + icon: React.ReactNode; + separatorAfter?: boolean; +}[] = [ + { label: "All", value: "all", icon: }, + { + label: "Unhandled", + value: "unhandled", + icon: , + separatorAfter: true, + }, + { + label: "Unsubscribed", + value: "unsubscribed", + icon: , + }, + { + label: "Skip Inbox", + value: "autoArchived", + icon: , + }, + { + label: "Approved", + value: "approved", + icon: , + }, +]; const selectOptions = [ { label: "Last week", value: "7" }, @@ -59,8 +107,6 @@ const selectOptions = [ ]; const defaultSelected = selectOptions[2]; -type Newsletter = NewsletterStatsResponse["newsletters"][number]; - export function BulkUnsubscribe() { const windowSize = useWindowSize(); const isMobile = windowSize.width < 768; @@ -121,12 +167,12 @@ export function BulkUnsubscribe() { ); const { typesArray } = useEmailsToIncludeFilter(); - const { filtersArray, filters, setFilters } = useNewsletterFilter(); + const { filtersArray, filter, setFilter } = useNewsletterFilter(); const posthog = usePostHog(); const [search, setSearch] = useState(""); - const { expanded, extra } = useExpanded(); + const [expanded, setExpanded] = useState(false); const params: NewsletterStatsQuery = { types: typesArray, @@ -219,11 +265,7 @@ export function BulkUnsubscribe() { ); }); - const onlyUnhandled = - filters.unhandled && - !filters.autoArchived && - !filters.unsubscribed && - !filters.approved; + const selectedFilter = filterOptions.find((opt) => opt.value === filter); return ( @@ -263,89 +305,34 @@ export function BulkUnsubscribe() {
}> -
-
- - setFilters( - onlyUnhandled - ? { - unhandled: true, - autoArchived: true, - unsubscribed: true, - approved: true, - } - : { - unhandled: true, - autoArchived: false, - unsubscribed: false, - approved: false, - }, - ) - } - /> -
-
- - } - keepOpenOnSelect - columns={[ - { - label: "All", - separatorAfter: true, - checked: - filters.approved && - filters.autoArchived && - filters.unsubscribed && - filters.unhandled, - setChecked: () => - setFilters({ - approved: true, - autoArchived: true, - unsubscribed: true, - unhandled: true, - }), - }, - { - label: "Unhandled", - checked: filters.unhandled, - setChecked: () => - setFilters({ - ...filters, - unhandled: !filters.unhandled, - }), - }, - { - label: "Unsubscribed", - checked: filters.unsubscribed, - setChecked: () => - setFilters({ - ...filters, - unsubscribed: !filters.unsubscribed, - }), - }, - { - label: "Skip Inbox", - checked: filters.autoArchived, - setChecked: () => - setFilters({ - ...filters, - autoArchived: !filters.autoArchived, - }), - }, - { - label: "Approved", - checked: filters.approved, - setChecked: () => - setFilters({ ...filters, approved: !filters.approved }), - }, - ]} - /> + + + + + + {filterOptions.map((option) => ( +
+ setFilter(option.value)} + className="flex items-center justify-between" + > + + {option.icon} + {option.label} + + {filter === option.value && ( + + )} + + {option.separatorAfter && } +
+ ))} +
+
+
@@ -393,12 +381,33 @@ export function BulkUnsubscribe() { onToggleSelectAll={onToggleSelectAll} /> )} -
{extra}
+ {/* Only show expand/collapse when there might be more results */} + {(expanded || (rows && rows.length >= 50)) && ( +
+ +
+ )} ) : ( -

- No emails found. To see more, adjust the filter options or click - the "Load More" button. +

+ No emails found. Adjust the filters, or click "Load More".

)} diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar.tsx index 1fe08a2f27..44715be540 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar.tsx @@ -49,7 +49,7 @@ export function SearchBar({ {showSearch && ( -
+ e.preventDefault()}> ({ ]); } +export type NewsletterFilterType = + | "all" + | "unhandled" + | "unsubscribed" + | "autoArchived" + | "approved"; + export function useNewsletterFilter() { - const [filters, setFilters] = useState< - Record<"unhandled" | "unsubscribed" | "autoArchived" | "approved", boolean> - >({ - unhandled: true, - unsubscribed: true, - autoArchived: true, - approved: true, - }); + const [filter, setFilter] = useState("all"); + + // Convert single filter to array format for API compatibility + const filtersArray: ( + | "unhandled" + | "unsubscribed" + | "autoArchived" + | "approved" + )[] = + filter === "all" + ? ["unhandled", "unsubscribed", "autoArchived", "approved"] + : [filter]; return { - filters, - filtersArray: Object.entries(filters) - .filter(([, selected]) => selected) - .map(([key]) => key) as ( - | "unhandled" - | "unsubscribed" - | "autoArchived" - | "approved" - )[], - setFilters, + filter, + filtersArray, + setFilter, }; } diff --git a/apps/web/app/(app)/[emailAccountId]/stats/useExpanded.tsx b/apps/web/app/(app)/[emailAccountId]/stats/useExpanded.tsx index e8c9310e8e..d50788858b 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/useExpanded.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/useExpanded.tsx @@ -2,14 +2,27 @@ import { ChevronsDownIcon, ChevronsUpIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -export const useExpanded = () => { +export const useExpanded = (options?: { + /** Current number of results */ + resultCount?: number; + /** The limit used when not expanded (default: 50) */ + collapsedLimit?: number; +}) => { + const { resultCount, collapsedLimit = 50 } = options ?? {}; const [expanded, setExpanded] = useState(false); const toggleExpand = useCallback( () => setExpanded((expanded) => !expanded), [], ); + // Only show "Show more" if we have exactly the limit (meaning there might be more) + // Only show "Show less" if expanded + const shouldShowButton = + expanded || (resultCount !== undefined && resultCount >= collapsedLimit); + const extra = useMemo(() => { + if (!shouldShowButton) return null; + return (
); - }, [expanded, toggleExpand]); + }, [expanded, toggleExpand, shouldShowButton]); return { expanded, extra }; }; diff --git a/apps/web/app/api/user/stats/newsletters/route.ts b/apps/web/app/api/user/stats/newsletters/route.ts index 8983e4eaee..d9ffe2c399 100644 --- a/apps/web/app/api/user/stats/newsletters/route.ts +++ b/apps/web/app/api/user/stats/newsletters/route.ts @@ -180,11 +180,11 @@ async function getNewsletterCounts( Prisma.sql`"emailAccountId" = ${options.emailAccountId}`, ); - // Add search filter if provided - use position() for literal substring matching + // Add search filter if provided - search both from (email) and fromName fields if (options.search) { const searchTerm = options.search.toLowerCase(); whereConditions.push( - Prisma.sql`position(${searchTerm} in LOWER("from")) > 0`, + Prisma.sql`(position(${searchTerm} in LOWER("from")) > 0 OR position(${searchTerm} in LOWER(COALESCE("fromName", ''))) > 0)`, ); } diff --git a/apps/web/prisma.config.ts b/apps/web/prisma.config.ts index bbf72e260c..060374d0b5 100644 --- a/apps/web/prisma.config.ts +++ b/apps/web/prisma.config.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { defineConfig } from "prisma/config"; export default defineConfig({ diff --git a/apps/web/prisma/migrations/20251204222441_fromname_index/migration.sql b/apps/web/prisma/migrations/20251204222441_fromname_index/migration.sql new file mode 100644 index 0000000000..371896c1b7 --- /dev/null +++ b/apps/web/prisma/migrations/20251204222441_fromname_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "EmailMessage_emailAccountId_fromName_idx" ON "EmailMessage"("emailAccountId", "fromName"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 23f13d946e..05041e3f55 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -702,6 +702,7 @@ model EmailMessage { @@index([emailAccountId, threadId]) @@index([emailAccountId, date]) @@index([emailAccountId, from]) + @@index([emailAccountId, fromName]) } model ThreadTracker { From fb275844cf8eb687d1ceb5250b8cddc22821e09b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:40:40 -0500 Subject: [PATCH 3/4] show disabled if user doesnt have tinybird --- .../[emailAccountId]/stats/EmailActionsAnalytics.tsx | 11 +++++++++++ apps/web/app/api/user/stats/email-actions/route.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics.tsx b/apps/web/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics.tsx index 6e689d6bf9..83d66703c9 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics.tsx @@ -19,6 +19,17 @@ export function EmailActionsAnalytics() { "/api/user/stats/email-actions", ); + if (data?.disabled) { + return ( + +

How many emails you've archived and deleted with Inbox Zero

+
+

This feature is disabled. Contact your admin to enable it.

+
+
+ ); + } + return ( >; async function getEmailActionStats({ userEmail }: { userEmail: string }) { + if (!isTinybirdEnabled()) { + return { result: [], disabled: true as const }; + } + const result = ( await getEmailActionsByDay({ ownerEmail: userEmail }) ).data.map((d) => ({ @@ -15,7 +19,7 @@ async function getEmailActionStats({ userEmail }: { userEmail: string }) { Deleted: d.delete_count, })); - return { result }; + return { result, disabled: false as const }; } export const GET = withEmailAccount( From 21ccfe8467616b9ea5770afbf0766644f2ab6c9e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:42:22 -0500 Subject: [PATCH 4/4] v2.21.47 --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index 22f1d32fc2..65658bf724 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.21.46 +v2.21.47