From 7cd6c6870a909c3b1792dbe4e2c50d10a4d77d7a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:48:25 +0300 Subject: [PATCH 1/2] Show names for bulk unsub --- .../BulkUnsubscribeDesktop.tsx | 37 +++++++----- .../BulkUnsubscribeMobile.tsx | 2 +- .../BulkUnsubscribeSection.tsx | 4 -- .../bulk-unsubscribe/types.ts | 1 + .../app/api/user/stats/newsletters/route.ts | 58 ++++++++++--------- .../migration.sql | 2 + apps/web/prisma/schema.prisma | 1 + apps/web/utils/actions/stats.ts | 21 +++---- version.txt | 2 +- 9 files changed, 69 insertions(+), 59 deletions(-) create mode 100644 apps/web/prisma/migrations/20251016181540_email_message_name/migration.sql diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index a5c2726837..b0f5c66649 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -106,8 +106,13 @@ export function BulkUnsubscribeRowDesktop({ onChange={() => onToggleSelect?.(item.name)} /> - - {item.name} + +
+ {item.fromName || item.name} + {item.fromName && ( + {item.name} + )} +
{item.value} @@ -136,19 +141,21 @@ export function BulkUnsubscribeRowDesktop({
{Math.round(archivedPercentage)}%
- - + +
+ +
); diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx index bd595404dd..00db3d511a 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeMobile.tsx @@ -46,7 +46,7 @@ export function BulkUnsubscribeRowMobile({ archivedPercentage, emailAccountId, }: RowProps) { - const name = extractNameFromEmail(item.name); + const name = item.fromName || extractNameFromEmail(item.name); const email = extractEmailAddress(item.name); const posthog = usePostHog(); diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index 5ba5650c12..e5f4dd6e95 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -273,10 +273,6 @@ export function BulkUnsubscribe() { /> -
- -
- { - // Collect SQL query conditions - const whereConditions: string[] = []; - const queryParams: Array = []; + // Build WHERE conditions using Prisma.sql for type safety + const whereConditions: Prisma.Sql[] = []; // Add date filters if provided if (options.fromDate) { + const fromTimestamp = (options.fromDate / 1000).toString(); whereConditions.push( - `"date" >= to_timestamp($${queryParams.length + 1}::double precision)`, + Prisma.sql`"date" >= to_timestamp(${fromTimestamp}::double precision)`, ); - queryParams.push((options.fromDate / 1000).toString()); // Convert milliseconds to seconds } if (options.toDate) { + const toTimestamp = (options.toDate / 1000).toString(); whereConditions.push( - `"date" <= to_timestamp($${queryParams.length + 1}::double precision)`, + Prisma.sql`"date" <= to_timestamp(${toTimestamp}::double precision)`, ); - queryParams.push((options.toDate / 1000).toString()); // Convert milliseconds to seconds } // Add read/unread filters if (options.read) { - whereConditions.push("read = true"); + whereConditions.push(Prisma.sql`read = true`); } else if (options.unread) { - whereConditions.push("read = false"); + whereConditions.push(Prisma.sql`read = false`); } // Add inbox/archived filters if (options.unarchived) { - whereConditions.push("inbox = true"); + whereConditions.push(Prisma.sql`inbox = true`); } else if (options.archived) { - whereConditions.push("inbox = false"); + whereConditions.push(Prisma.sql`inbox = false`); } - // Always filter by userId - whereConditions.push(`"emailAccountId" = $${queryParams.length + 1}`); - queryParams.push(options.emailAccountId); + // Always filter by emailAccountId + whereConditions.push( + Prisma.sql`"emailAccountId" = ${options.emailAccountId}`, + ); - // Create WHERE clause - const whereClause = whereConditions.length - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; + // Join conditions with AND + const whereClause = + whereConditions.length > 0 + ? Prisma.sql`WHERE ${Prisma.join(whereConditions, " AND ")}` + : Prisma.empty; - // Build order by clause + // Build order by clause (safe, no user input) const orderByClause = options.orderBy ? getOrderByClause(options.orderBy) : '"count" DESC'; - // Build limit clause + // Build limit clause (safe, validated number) const limitClause = options.limit ? `LIMIT ${options.limit}` : ""; - // Wrap in a subquery so we can use aliases in ORDER BY + // Build the complete query using Prisma.sql const query = Prisma.sql` WITH email_message_stats AS ( SELECT "from", + MAX("fromName") as "fromName", COUNT(*)::int as "count", SUM(CASE WHEN inbox = true THEN 1 ELSE 0 END)::int as "inboxEmails", SUM(CASE WHEN read = true THEN 1 ELSE 0 END)::int as "readEmails", MAX("unsubscribeLink") as "unsubscribeLink" FROM "EmailMessage" - ${Prisma.raw(whereClause)} + ${whereClause} GROUP BY "from" ) SELECT * FROM email_message_stats @@ -205,15 +210,12 @@ async function getNewsletterCounts( `; try { - const results = await prisma.$queryRawUnsafe( - query.sql, - ...queryParams, - ...query.values, - ); + const results = await prisma.$queryRaw(query); // Convert BigInt values to regular numbers return results.map((result) => ({ from: result.from, + fromName: result.fromName, count: result.count, inboxEmails: result.inboxEmails, readEmails: result.readEmails, diff --git a/apps/web/prisma/migrations/20251016181540_email_message_name/migration.sql b/apps/web/prisma/migrations/20251016181540_email_message_name/migration.sql new file mode 100644 index 0000000000..8a37d842b2 --- /dev/null +++ b/apps/web/prisma/migrations/20251016181540_email_message_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EmailMessage" ADD COLUMN "fromName" TEXT; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 858695e4b1..9129aacf84 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -658,6 +658,7 @@ model EmailMessage { messageId String date DateTime // date of the email from String + fromName String? // sender's display name fromDomain String to String unsubscribeLink String? diff --git a/apps/web/utils/actions/stats.ts b/apps/web/utils/actions/stats.ts index 8d78ad4378..ef48bf7310 100644 --- a/apps/web/utils/actions/stats.ts +++ b/apps/web/utils/actions/stats.ts @@ -4,12 +4,17 @@ import { actionClient } from "@/utils/actions/safe-action"; import { z } from "zod"; import { createEmailProvider } from "@/utils/email/provider"; import { isDefined } from "@/utils/types"; -import { extractDomainFromEmail, extractEmailAddress } from "@/utils/email"; +import { + extractDomainFromEmail, + extractEmailAddress, + extractNameFromEmail, +} from "@/utils/email"; import { findUnsubscribeLink } from "@/utils/parse/parseHtml.server"; import { internalDateToDate } from "@/utils/date"; import prisma from "@/utils/prisma"; import { SafeError } from "@/utils/error"; import type { Logger } from "@/utils/logger"; +import type { EmailProvider } from "@/utils/email/types"; const PAGE_SIZE = 20; // avoid setting too high because it will hit the rate limit // const PAUSE_AFTER_RATE_LIMIT = 10_000; @@ -62,7 +67,7 @@ async function loadEmails( logger, }: { emailAccountId: string; - emailProvider: any; + emailProvider: EmailProvider; logger: Logger; }, { loadBefore }: { loadBefore: boolean }, @@ -145,7 +150,7 @@ async function saveBatch({ after, }: { emailAccountId: string; - emailProvider: any; + emailProvider: EmailProvider; logger: Logger; nextPageToken?: string; } & ( @@ -153,7 +158,6 @@ async function saveBatch({ | { before: undefined; after: Date } | { before: undefined; after: undefined } )) { - // Get messages from the provider with date filtering const res = await emailProvider.getMessagesWithPagination({ maxResults: PAGE_SIZE, pageToken: nextPageToken, @@ -161,15 +165,12 @@ async function saveBatch({ after, }); - // Get full message details for the batch const messages = await emailProvider.getMessagesBatch( - res.messages?.map((m: any) => m.id).filter(isDefined) || [], + res.messages?.map((m) => m.id).filter(isDefined) || [], ); const emailsToSave = messages - .map((m: any) => { - if (!m.id || !m.threadId) return; - + .map((m) => { const unsubscribeLink = findUnsubscribeLink(m.textHtml) || m.headers["list-unsubscribe"]; @@ -186,6 +187,7 @@ async function saveBatch({ threadId: m.threadId, messageId: m.id, from: extractEmailAddress(m.headers.from), + fromName: extractNameFromEmail(m.headers.from), fromDomain: extractDomainFromEmail(m.headers.from), to: m.headers.to ? extractEmailAddress(m.headers.to) : "Missing", date, @@ -201,7 +203,6 @@ async function saveBatch({ logger.info("Saving", { count: emailsToSave.length }); - // Use createMany for better performance await prisma.emailMessage.createMany({ data: emailsToSave, skipDuplicates: true, // Skip if email already exists (based on unique constraint) diff --git a/version.txt b/version.txt index e9c0ff5d72..18f22918a9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.16.9 +v2.16.10 From ff612abebf4b3a1872fc34e9769708447eb430d6 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:51:11 +0300 Subject: [PATCH 2/2] tighten ui --- .../bulk-unsubscribe/BulkUnsubscribeDesktop.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index b0f5c66649..1627ebd513 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -106,7 +106,7 @@ export function BulkUnsubscribeRowDesktop({ onChange={() => onToggleSelect?.(item.name)} />
- +
{item.fromName || item.name} {item.fromName && (