Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,13 @@ export function BulkUnsubscribeRowDesktop({
onChange={() => onToggleSelect?.(item.name)}
/>
</TableCell>
<TableCell className="max-w-[250px] truncate min-[1550px]:max-w-[300px] min-[1650px]:max-w-[400px]">
{item.name}
<TableCell className="max-w-[250px] truncate min-[1550px]:max-w-[300px] min-[1650px]:max-w-[400px] py-3">
<div className="flex flex-col">
<span className="font-medium">{item.fromName || item.name}</span>
{item.fromName && (
<span className="text-xs text-muted-foreground">{item.name}</span>
)}
</div>
</TableCell>
<TableCell>{item.value}</TableCell>
<TableCell>
Expand Down Expand Up @@ -136,19 +141,21 @@ export function BulkUnsubscribeRowDesktop({
</div>
<div className="2xl:hidden">{Math.round(archivedPercentage)}%</div>
</TableCell>
<TableCell className="flex justify-end gap-2 p-2">
<ActionCell
item={item}
hasUnsubscribeAccess={hasUnsubscribeAccess}
mutate={mutate}
refetchPremium={refetchPremium}
onOpenNewsletter={onOpenNewsletter}
selected={selected}
labels={labels}
openPremiumModal={openPremiumModal}
userEmail={userEmail}
emailAccountId={emailAccountId}
/>
<TableCell className="p-1">
<div className="flex justify-end items-center gap-2">
<ActionCell
item={item}
hasUnsubscribeAccess={hasUnsubscribeAccess}
mutate={mutate}
refetchPremium={refetchPremium}
onOpenNewsletter={onOpenNewsletter}
selected={selected}
labels={labels}
openPremiumModal={openPremiumModal}
userEmail={userEmail}
emailAccountId={emailAccountId}
/>
</div>
</TableCell>
</TableRow>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,6 @@ export function BulkUnsubscribe() {
/>
</div>

<div className="hidden md:block">
<ShortcutTooltip />
</div>

<SearchBar onSearch={setSearch} />

<DetailedStatsFilter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { UserResponse } from "@/app/api/user/me/route";

export type Row = {
name: string;
fromName?: string;
unsubscribeLink?: string | null;
status?: NewsletterStatus | null;
autoArchived?: { id?: string | null };
Expand Down
58 changes: 30 additions & 28 deletions apps/web/app/api/user/stats/newsletters/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { withEmailProvider } from "@/utils/middleware";
import { extractEmailAddress } from "@/utils/email";
import { createScopedLogger } from "@/utils/logger";
import prisma from "@/utils/prisma";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import type { EmailProvider } from "@/utils/email/types";
import {
getAutoArchiveFilters,
Expand Down Expand Up @@ -89,6 +89,7 @@ async function getEmailMessages(
const from = extractEmailAddress(email.from);
return {
name: from,
fromName: email.fromName || "",
value: email.count,
inboxEmails: email.inboxEmails,
readEmails: email.readEmails,
Expand All @@ -111,6 +112,7 @@ async function getEmailMessages(

type NewsletterCountResult = {
from: string;
fromName: string | null;
count: number;
inboxEmails: number;
readEmails: number;
Expand All @@ -119,6 +121,7 @@ type NewsletterCountResult = {

type NewsletterCountRawResult = {
from: string;
fromName: string | null;
count: number;
inboxEmails: number;
readEmails: number;
Expand All @@ -136,67 +139,69 @@ async function getNewsletterCounts(
andClause?: boolean;
},
): Promise<NewsletterCountResult[]> {
// Collect SQL query conditions
const whereConditions: string[] = [];
const queryParams: Array<string | Date> = [];
// 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
Expand All @@ -205,15 +210,12 @@ async function getNewsletterCounts(
`;

try {
const results = await prisma.$queryRawUnsafe<NewsletterCountRawResult[]>(
query.sql,
...queryParams,
...query.values,
);
const results = await prisma.$queryRaw<NewsletterCountRawResult[]>(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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EmailMessage" ADD COLUMN "fromName" TEXT;
1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
21 changes: 11 additions & 10 deletions apps/web/utils/actions/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,7 +67,7 @@ async function loadEmails(
logger,
}: {
emailAccountId: string;
emailProvider: any;
emailProvider: EmailProvider;
logger: Logger;
},
{ loadBefore }: { loadBefore: boolean },
Expand Down Expand Up @@ -145,31 +150,27 @@ async function saveBatch({
after,
}: {
emailAccountId: string;
emailProvider: any;
emailProvider: EmailProvider;
logger: Logger;
nextPageToken?: string;
} & (
| { before: Date; after: undefined }
| { 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,
before,
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"];

Expand All @@ -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,
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.16.9
v2.16.10
Loading