From fb237bb0d7a8825fcaaf851df49c39d2ae455d4a Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Wed, 16 Jul 2025 22:45:13 -0300 Subject: [PATCH 1/4] Digest fixes --- apps/web/app/api/ai/digest/route.ts | 5 ++++- apps/web/app/api/ai/digest/validation.ts | 2 +- apps/web/utils/digest/index.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/ai/digest/route.ts b/apps/web/app/api/ai/digest/route.ts index 1538d54776..08ba8ffa5a 100644 --- a/apps/web/app/api/ai/digest/route.ts +++ b/apps/web/app/api/ai/digest/route.ts @@ -36,7 +36,10 @@ export async function POST(request: Request) { const summary = await aiSummarizeEmailForDigest({ ruleName, emailAccount, - messageToSummarize: message, + messageToSummarize: { + ...message, + to: message.to || "", + }, }); await upsertDigest({ diff --git a/apps/web/app/api/ai/digest/validation.ts b/apps/web/app/api/ai/digest/validation.ts index c6f5154d40..d9d9d5a477 100644 --- a/apps/web/app/api/ai/digest/validation.ts +++ b/apps/web/app/api/ai/digest/validation.ts @@ -8,7 +8,7 @@ export const digestBody = z.object({ id: z.string(), threadId: z.string(), from: z.string(), - to: z.string(), + to: z.string().optional(), subject: z.string(), content: z.string(), }), diff --git a/apps/web/utils/digest/index.ts b/apps/web/utils/digest/index.ts index 3184ed9e78..d733a3714e 100644 --- a/apps/web/utils/digest/index.ts +++ b/apps/web/utils/digest/index.ts @@ -32,7 +32,7 @@ export async function enqueueDigestItem({ id: email.id, threadId: email.threadId, from: email.headers.from, - to: email.headers.to, + to: email.headers.to || "", subject: email.headers.subject, content: email.textPlain || "", }, From f09ee42e120bf51b6df63dbafe18045c3f67a610 Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Thu, 17 Jul 2025 01:12:31 -0300 Subject: [PATCH 2/4] Digest fixes --- apps/web/app/api/resend/digest/all/route.ts | 3 +- apps/web/app/api/resend/digest/route.ts | 67 ++-- apps/web/app/api/resend/digest/validation.ts | 22 +- packages/resend/emails/digest.tsx | 345 ++++++------------- 4 files changed, 144 insertions(+), 293 deletions(-) diff --git a/apps/web/app/api/resend/digest/all/route.ts b/apps/web/app/api/resend/digest/all/route.ts index bedbfb2b2f..e7ac503b0d 100644 --- a/apps/web/app/api/resend/digest/all/route.ts +++ b/apps/web/app/api/resend/digest/all/route.ts @@ -62,8 +62,7 @@ async function sendDigestAllUpdate() { queueName: "email-digest-all", parallelism: 3, // Allow up to 3 concurrent jobs from this queue url, - body: { emailAccountId: emailAccount.id }, - headers: getCronSecretHeader(), + body: { emailAccountId: emailAccount.id, CRON_SECRET: env.CRON_SECRET }, }); } catch (error) { logger.error("Failed to publish to Qstash", { diff --git a/apps/web/app/api/resend/digest/route.ts b/apps/web/app/api/resend/digest/route.ts index 29f5fb4e73..f9e73ae1d4 100644 --- a/apps/web/app/api/resend/digest/route.ts +++ b/apps/web/app/api/resend/digest/route.ts @@ -168,39 +168,36 @@ async function sendEmail({ item.action?.executedRule?.rule?.name || RuleName.ColdEmail, ); - // Only include if it's one of our known categories - const categoryResult = digestCategorySchema.safeParse(ruleName); - if (categoryResult.success) { - const category = categoryResult.data; - if (!acc[category]) { - acc[category] = []; - } - - let parsedContent: unknown; - try { - parsedContent = JSON.parse(item.content); - } catch (error) { - logger.warn("Failed to parse digest item content, skipping item", { - messageId: item.messageId, - digestId: digest.id, - error: error instanceof Error ? error.message : "Unknown error", - }); - return; // Skip this item and continue with the next one - } - - const contentResult = - DigestEmailSummarySchema.safeParse(parsedContent); - - if (contentResult.success) { - acc[category].push({ - content: { - entries: contentResult.data?.entries || [], - summary: contentResult.data?.summary, - }, - from: extractNameFromEmail(message?.headers?.from || ""), - subject: message?.headers?.subject || "", - }); - } + // Use ruleName directly as category + const category = ruleName; + if (!acc[category]) { + acc[category] = []; + } + + let parsedContent: unknown; + try { + parsedContent = JSON.parse(item.content); + } catch (error) { + logger.warn("Failed to parse digest item content, skipping item", { + messageId: item.messageId, + digestId: digest.id, + error: error instanceof Error ? error.message : "Unknown error", + }); + return; // Skip this item and continue with the next one + } + + const contentResult = + DigestEmailSummarySchema.safeParse(parsedContent); + + if (contentResult.success) { + acc[category].push({ + content: { + entries: contentResult.data?.entries || [], + summary: contentResult.data?.summary, + }, + from: extractNameFromEmail(message?.headers?.from || ""), + subject: message?.headers?.subject || "", + }); } }); return acc; @@ -213,11 +210,11 @@ async function sendEmail({ from: env.RESEND_FROM_EMAIL, to: emailAccount.email, emailProps: { - ...executedRulesByRule, baseUrl: env.NEXT_PUBLIC_BASE_URL, unsubscribeToken: token, date: new Date(), - }, + ...executedRulesByRule, + } as any, // Type assertion needed due to generic DigestEmailProps }); // Only update database if email sending succeeded diff --git a/apps/web/app/api/resend/digest/validation.ts b/apps/web/app/api/resend/digest/validation.ts index 43e23352e5..30b28aa3e3 100644 --- a/apps/web/app/api/resend/digest/validation.ts +++ b/apps/web/app/api/resend/digest/validation.ts @@ -30,25 +30,9 @@ export const digestSummarySchema = z.string().transform((str) => { } }); -export const digestCategorySchema = z.enum([ - "newsletter", - "receipt", - "marketing", - "calendar", - "coldEmail", - "notification", - "toReply", -]); - -export const digestSchema = z.object({ - newsletter: z.array(digestItemSchema).optional(), - receipt: z.array(digestItemSchema).optional(), - marketing: z.array(digestItemSchema).optional(), - calendar: z.array(digestItemSchema).optional(), - coldEmail: z.array(digestItemSchema).optional(), - notification: z.array(digestItemSchema).optional(), - toReply: z.array(digestItemSchema).optional(), -}); +export const digestCategorySchema = z.string(); + +export const digestSchema = z.record(z.string(), z.array(digestItemSchema).optional()); export const sendDigestEmailBody = z.object({ emailAccountId: z.string() }); diff --git a/packages/resend/emails/digest.tsx b/packages/resend/emails/digest.tsx index 17ef10a793..a6b9227fb1 100644 --- a/packages/resend/emails/digest.tsx +++ b/packages/resend/emails/digest.tsx @@ -75,30 +75,19 @@ const colorClasses = { }, } as const; -export interface DigestEmailProps { +export type DigestEmailProps = { baseUrl: string; unsubscribeToken: string; date?: Date; - newsletter?: DigestItem[] | undefined; - receipt?: DigestItem[] | undefined; - marketing?: DigestItem[] | undefined; - calendar?: DigestItem[] | undefined; - coldEmail?: DigestItem[] | undefined; - notification?: DigestItem[] | undefined; - toReply?: DigestItem[] | undefined; -} + [key: string]: DigestItem[] | undefined | string | Date | undefined; +}; export default function DigestEmail(props: DigestEmailProps) { const { baseUrl = "https://www.getinboxzero.com", - newsletter = [], - receipt = [], - marketing = [], - calendar = [], - coldEmail = [], - notification = [], - toReply = [], unsubscribeToken, + date, + ...digestData } = props; const availableCategories = { @@ -146,15 +135,22 @@ export default function DigestEmail(props: DigestEmailProps) { }, }; - const getCategoryInfo = (key: keyof typeof availableCategories) => { - return availableCategories[key]; + const getCategoryInfo = (key: string) => { + if (key in availableCategories) { + return availableCategories[key as keyof typeof availableCategories]; + } + // Fallback for unknown categories + return { + name: key.charAt(0).toUpperCase() + key.slice(1), + emoji: "📂", + color: "gray", + href: `#${key}`, + }; }; const getCategoriesWithItemsCount = () => { - return Object.entries(availableCategories).filter( - ([key]) => - Array.isArray(props[key as keyof DigestEmailProps]) && - (props[key as keyof DigestEmailProps] as unknown as any[])?.length > 0, + return Object.keys(digestData).filter( + (key) => Array.isArray(digestData[key]) && (digestData[key]?.length ?? 0) > 0 ).length; }; @@ -170,198 +166,42 @@ export default function DigestEmail(props: DigestEmailProps) { * @returns Renders a grid of categories with a count of the number of emails in each category. */ const renderCategoryGrid = () => { - const categories = Object.entries(availableCategories) - .map(([key, value]) => ({ - key, - ...value, - count: Array.isArray(props[key as keyof DigestEmailProps]) - ? (props[key as keyof DigestEmailProps] as unknown as any[]) - ?.length || 0 - : 0, - })) - .filter((cat) => cat.count > 0); + // Get all present categories in digestData + const categories = Object.keys(digestData) + .filter((key) => Array.isArray(digestData[key]) && (digestData[key]?.length ?? 0) > 0) + .map((key) => { + const info = getCategoryInfo(key); + return { + key, + ...info, + count: (digestData[key] as DigestItem[]).length, + }; + }); const categoryCount = categories.length; + if (categoryCount === 0) return null; - if (categoryCount <= 1) return null; - - // For 2 categories: single row - if (categoryCount === 2) { - return ( - - {categories.map((category, index) => ( - - -
- - {category.emoji} {category.name} - -
- - {category.count} - -
-
- -
- ))} -
- ); - } - - // For 3-4 categories: 2x2 grid - if (categoryCount <= 4) { - const rows = []; - for (let i = 0; i < categoryCount; i += 2) { - const isLastRow = i + 2 >= categoryCount; - rows.push( - - - -
- - {categories[i].emoji} {categories[i].name} - -
- - {categories[i].count} - -
-
- -
- {i + 1 < categoryCount && ( - - -
- - {categories[i + 1].emoji} {categories[i + 1].name} - -
- - {categories[i + 1].count} - -
-
- -
- )} -
, - ); - } - return rows; - } - - // For 5-7 categories: 2x2 grid + bottom row + // For all cases: ensure max 2 items per row const rows = []; - // First two rows (4 categories) - for (let i = 0; i < 4; i += 2) { + const totalRows = Math.ceil(categoryCount / 2); + + for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) { + const startIndex = rowIndex * 2; + const endIndex = Math.min(startIndex + 2, categoryCount); + const isLastRow = rowIndex === totalRows - 1; + const itemsInThisRow = endIndex - startIndex; + rows.push( - - - -
- - {categories[i].emoji} {categories[i].name} - -
- - {categories[i].count} - -
-
- -
- - -
- - {categories[i + 1].emoji} {categories[i + 1].name} - -
- - {categories[i + 1].count} - -
-
- -
-
, - ); - } - - // Bottom row for remaining categories - const remainingCategories = categories.slice(4); - const remainingCount = remainingCategories.length; - - if (remainingCount > 0) { - const widthClass = - remainingCount === 1 - ? "w-[100%]" - : remainingCount === 2 - ? "w-[50%]" - : "w-[33.33%]"; - - rows.push( - - {remainingCategories.map((category, index) => ( + + {categories.slice(startIndex, endIndex).map((category, index) => ( @@ -374,7 +214,7 @@ export default function DigestEmail(props: DigestEmailProps) { {category.emoji} {category.name}
))} - , + ); } - + return rows; }; + // Return early if no digest items are found + const hasItems = Object.keys(digestData).some( + (key) => Array.isArray(digestData[key]) && (digestData[key]?.length ?? 0) > 0 + ); + + if (!hasItems) { + return null; + } + + // CategorySection now accepts a generic categoryInfo const CategorySection = ({ categoryKey, items, }: { - categoryKey: keyof typeof availableCategories; + categoryKey: string; items: DigestItem[]; }) => { if (items.length === 0) return null; - const category = getCategoryInfo(categoryKey); - const colors = colorClasses[category.color as keyof typeof colorClasses]; - + const colors = colorClasses[category.color as keyof typeof colorClasses] || colorClasses.gray; return (
@@ -456,20 +304,6 @@ export default function DigestEmail(props: DigestEmailProps) { ); }; - // Return early if no digest items are found - const hasItems = - newsletter.length > 0 || - receipt.length > 0 || - marketing.length > 0 || - calendar.length > 0 || - coldEmail.length > 0 || - notification.length > 0 || - toReply.length > 0; - - if (!hasItems) { - return null; - } - return ( @@ -501,17 +335,21 @@ export default function DigestEmail(props: DigestEmailProps) {
- {getCategoriesWithItemsCount() > 1 && ( -
{renderCategoryGrid()}
+ {getCategoriesWithItemsCount() > 0 && ( +
+ {renderCategoryGrid()} +
)} - - - - - - - + {Object.keys(digestData).map((categoryKey) => ( + Array.isArray(digestData[categoryKey]) && digestData[categoryKey]?.length > 0 ? ( + + ) : null + ))}