diff --git a/apps/web/app/api/ai/digest/route.ts b/apps/web/app/api/ai/digest/route.ts index a18a5ea29e..03171c67b0 100644 --- a/apps/web/app/api/ai/digest/route.ts +++ b/apps/web/app/api/ai/digest/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; import { digestBody } from "./validation"; import { DigestStatus } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; @@ -7,9 +8,57 @@ import { RuleName } from "@/utils/rule/consts"; import { getRuleNameByExecutedAction } from "@/utils/actions/rule"; import { aiSummarizeEmailForDigest } from "@/utils/ai/digest/summarize-email-for-digest"; import { getEmailAccountWithAi } from "@/utils/user/get"; -import type { DigestEmailSummarySchema } from "@/app/api/resend/digest/validation"; +import type { StoredDigestContent } from "@/app/api/resend/digest/validation"; import { withError } from "@/utils/middleware"; -import { verifySignatureAppRouter } from "@upstash/qstash/dist/nextjs"; + +export const POST = withError( + verifySignatureAppRouter(async (request: Request) => { + const logger = createScopedLogger("digest"); + + try { + const body = digestBody.parse(await request.json()); + const { emailAccountId, coldEmailId, actionId, message } = body; + + logger.with({ emailAccountId, messageId: message.id }); + + const emailAccount = await getEmailAccountWithAi({ + emailAccountId, + }); + if (!emailAccount) { + throw new Error("Email account not found"); + } + + const ruleName = await resolveRuleName(actionId); + const summary = await aiSummarizeEmailForDigest({ + ruleName, + emailAccount, + messageToSummarize: { + ...message, + to: message.to || "", + }, + }); + + if (!summary?.content) { + logger.info("Skipping digest item because it is not worth summarizing"); + return new NextResponse("OK", { status: 200 }); + } + + await upsertDigest({ + messageId: message.id || "", + threadId: message.threadId || "", + emailAccountId, + actionId, + coldEmailId, + content: summary, + }); + + return new NextResponse("OK", { status: 200 }); + } catch (error) { + logger.error("Failed to process digest", { error }); + return new NextResponse("Internal Server Error", { status: 500 }); + } + }), +); async function resolveRuleName(actionId?: string): Promise { if (!actionId) return RuleName.ColdEmail; @@ -113,7 +162,7 @@ async function upsertDigest({ emailAccountId: string; actionId?: string; coldEmailId?: string; - content: DigestEmailSummarySchema; + content: StoredDigestContent; }) { const logger = createScopedLogger("digest").with({ messageId, @@ -156,52 +205,3 @@ async function upsertDigest({ throw error; } } - -export const POST = withError( - verifySignatureAppRouter(async (request: Request) => { - const logger = createScopedLogger("digest"); - - try { - const body = digestBody.parse(await request.json()); - const { emailAccountId, coldEmailId, actionId, message } = body; - - logger.with({ emailAccountId, messageId: message.id }); - - const emailAccount = await getEmailAccountWithAi({ - emailAccountId, - }); - if (!emailAccount) { - throw new Error("Email account not found"); - } - - const ruleName = await resolveRuleName(actionId); - const summary = await aiSummarizeEmailForDigest({ - ruleName, - emailAccount, - messageToSummarize: { - ...message, - to: message.to || "", - }, - }); - - if (!summary?.content) { - logger.info("Skipping digest item because it is not worth summarizing"); - return new NextResponse("OK", { status: 200 }); - } - - await upsertDigest({ - messageId: message.id || "", - threadId: message.threadId || "", - emailAccountId, - actionId, - coldEmailId, - content: summary, - }); - - return new NextResponse("OK", { status: 200 }); - } catch (error) { - logger.error("Failed to process digest", { error }); - return new NextResponse("Internal Server Error", { status: 500 }); - } - }), -); diff --git a/apps/web/app/api/clean/gmail/route.ts b/apps/web/app/api/clean/gmail/route.ts index 02560761b3..af6af1d8a0 100644 --- a/apps/web/app/api/clean/gmail/route.ts +++ b/apps/web/app/api/clean/gmail/route.ts @@ -1,5 +1,5 @@ import { type NextRequest, NextResponse } from "next/server"; -import { verifySignatureAppRouter } from "@upstash/qstash/dist/nextjs"; +import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; import { z } from "zod"; import { withError } from "@/utils/middleware"; import { getGmailClientWithRefresh } from "@/utils/gmail/client"; diff --git a/apps/web/app/api/clean/route.ts b/apps/web/app/api/clean/route.ts index 9ec47998a7..3eb5cd217b 100644 --- a/apps/web/app/api/clean/route.ts +++ b/apps/web/app/api/clean/route.ts @@ -1,4 +1,4 @@ -import { verifySignatureAppRouter } from "@upstash/qstash/dist/nextjs"; +import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; import { z } from "zod"; import { NextResponse } from "next/server"; import { withError } from "@/utils/middleware"; diff --git a/apps/web/app/api/resend/digest/route.ts b/apps/web/app/api/resend/digest/route.ts index 30e8f89111..a1aefd31d8 100644 --- a/apps/web/app/api/resend/digest/route.ts +++ b/apps/web/app/api/resend/digest/route.ts @@ -8,12 +8,15 @@ import { createScopedLogger } from "@/utils/logger"; import { createUnsubscribeToken } from "@/utils/unsubscribe"; import { calculateNextScheduleDate } from "@/utils/schedule"; import type { ParsedMessage } from "@/utils/types"; -import { sendDigestEmailBody, type Digest } from "./validation"; +import { + sendDigestEmailBody, + storedDigestContentSchema, + type Digest, +} from "./validation"; import { DigestStatus } from "@prisma/client"; import { extractNameFromEmail } from "../../../../utils/email"; import { RuleName } from "@/utils/rule/consts"; -import { verifySignatureAppRouter } from "@upstash/qstash/dist/nextjs"; -import { schema as digestEmailSummarySchema } from "@/utils/ai/digest/summarize-email-for-digest"; +import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; import { camelCase } from "lodash"; import { createEmailProvider } from "@/utils/email/provider"; import { sleep } from "@/utils/sleep"; @@ -27,7 +30,47 @@ type SendEmailResult = { message: string; }; -// Function to get digest schedule data separately +export const GET = withEmailAccount(async (request) => { + // send to self + const emailAccountId = request.auth.emailAccountId; + + logger.info("Sending digest email to user GET", { emailAccountId }); + + const result = await sendEmail({ emailAccountId, force: true }); + + return NextResponse.json(result); +}); + +export const POST = withError( + verifySignatureAppRouter(async (request: NextRequest) => { + const json = await request.json(); + const { success, data, error } = sendDigestEmailBody.safeParse(json); + + if (!success) { + logger.error("Invalid request body", { error }); + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + const { emailAccountId } = data; + + logger.info("Sending digest email to user POST", { emailAccountId }); + + try { + const result = await sendEmail({ emailAccountId }); + return NextResponse.json(result); + } catch (error) { + logger.error("Error sending digest email", { error }); + captureException(error); + return NextResponse.json( + { success: false, error: "Error sending digest email" }, + { status: 500 }, + ); + } + }), +); + async function getDigestSchedule({ emailAccountId, }: { @@ -199,14 +242,21 @@ async function sendEmail({ return; // Skip this item and continue with the next one } - const contentResult = digestEmailSummarySchema.safeParse(parsedContent); + const contentResult = + storedDigestContentSchema.safeParse(parsedContent); if (contentResult.success) { acc[ruleNameKey].push({ - content: contentResult.data, + content: contentResult.data.content, from: extractNameFromEmail(message?.headers?.from || ""), subject: message?.headers?.subject || "", }); + } else { + logger.warn("Failed to validate digest content structure", { + messageId: item.messageId, + digestId: digest.id, + error: contentResult.error, + }); } }); return acc; @@ -300,44 +350,3 @@ async function sendEmail({ return { success: true, message: "Digest email sent successfully" }; } - -export const GET = withEmailAccount(async (request) => { - // send to self - const emailAccountId = request.auth.emailAccountId; - - logger.info("Sending digest email to user GET", { emailAccountId }); - - const result = await sendEmail({ emailAccountId, force: true }); - - return NextResponse.json(result); -}); - -export const POST = withError( - verifySignatureAppRouter(async (request: NextRequest) => { - const json = await request.json(); - const { success, data, error } = sendDigestEmailBody.safeParse(json); - - if (!success) { - logger.error("Invalid request body", { error }); - return NextResponse.json( - { error: "Invalid request body" }, - { status: 400 }, - ); - } - const { emailAccountId } = data; - - logger.info("Sending digest email to user POST", { emailAccountId }); - - try { - const result = await sendEmail({ emailAccountId }); - return NextResponse.json(result); - } catch (error) { - logger.error("Error sending digest email", { error }); - captureException(error); - return NextResponse.json( - { success: false, error: "Error sending digest email" }, - { status: 500 }, - ); - } - }), -); diff --git a/apps/web/app/api/resend/digest/validation.ts b/apps/web/app/api/resend/digest/validation.ts index 31e1cb4552..3b0063bb7a 100644 --- a/apps/web/app/api/resend/digest/validation.ts +++ b/apps/web/app/api/resend/digest/validation.ts @@ -1,28 +1,15 @@ import { z } from "zod"; -import { schema as digestEmailSummarySchema } from "@/utils/ai/digest/summarize-email-for-digest"; -export type DigestEmailSummarySchema = z.infer; +export const storedDigestContentSchema = z.object({ content: z.string() }); +export type StoredDigestContent = z.infer; -export const digestItemSchema = z.object({ +const digestItemSchema = z.object({ from: z.string(), subject: z.string(), - content: digestEmailSummarySchema, + content: z.string(), }); -export const digestSummarySchema = z.string().transform((str) => { - try { - return digestEmailSummarySchema.parse(JSON.parse(str)); - } catch { - throw new Error("Invalid summary JSON"); - } -}); - -export const digestCategorySchema = z.string(); - -export const digestSchema = z.record( - z.string(), - z.array(digestItemSchema).optional(), -); +const digestSchema = z.record(z.string(), z.array(digestItemSchema).optional()); export const sendDigestEmailBody = z.object({ emailAccountId: z.string() }); diff --git a/apps/web/utils/ai/digest/summarize-email-for-digest.test.ts b/apps/web/utils/ai/digest/summarize-email-for-digest.test.ts index 0821d60761..78f0dd79c5 100644 --- a/apps/web/utils/ai/digest/summarize-email-for-digest.test.ts +++ b/apps/web/utils/ai/digest/summarize-email-for-digest.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiSummarizeEmailForDigest } from "@/utils/ai/digest/summarize-email-for-digest"; -import { schema as DigestEmailSummarySchema } from "@/utils/ai/digest/summarize-email-for-digest"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM } from "@/utils/types"; @@ -76,9 +75,10 @@ describe.runIf(isAiTest)("aiSummarizeEmailForDigest", () => { content: expect.any(String), }); - // Verify the result matches the schema - const validationResult = DigestEmailSummarySchema.safeParse(result); - expect(validationResult.success).toBe(true); + // Verify the result has the expected structure + expect(result).toBeDefined(); + expect(result).toHaveProperty("content"); + expect(typeof result?.content).toBe("string"); }, TIMEOUT, ); @@ -106,8 +106,10 @@ describe.runIf(isAiTest)("aiSummarizeEmailForDigest", () => { content: expect.any(String), }); - const validationResult = DigestEmailSummarySchema.safeParse(result); - expect(validationResult.success).toBe(true); + // Verify the result has the expected structure + expect(result).toBeDefined(); + expect(result).toHaveProperty("content"); + expect(typeof result?.content).toBe("string"); }, TIMEOUT, ); diff --git a/apps/web/utils/ai/digest/summarize-email-for-digest.ts b/apps/web/utils/ai/digest/summarize-email-for-digest.ts index ad5e7fbbbc..cd8398d78a 100644 --- a/apps/web/utils/ai/digest/summarize-email-for-digest.ts +++ b/apps/web/utils/ai/digest/summarize-email-for-digest.ts @@ -6,13 +6,12 @@ import { stringifyEmailSimple } from "@/utils/stringify-email"; import { getModel } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; -export const schema = z.object({ - content: z.string().describe("The content of the summary text"), -}); - const logger = createScopedLogger("summarize-digest-email"); -export type AISummarizeResult = z.infer; +const schema = z.object({ + content: z.string().describe("The content of the summary text"), +}); +type AISummarizeResult = z.infer; export async function aiSummarizeEmailForDigest({ ruleName, diff --git a/packages/resend/emails/digest.tsx b/packages/resend/emails/digest.tsx index f2d8267709..3c187b515f 100644 --- a/packages/resend/emails/digest.tsx +++ b/packages/resend/emails/digest.tsx @@ -179,16 +179,12 @@ export default function DigestEmail(props: DigestEmailProps) { toReply: "red", }; - // Return early if no digest data is found + // Check if there are any items to display const hasItems = Object.keys(digestData).some((key) => { const categoryData = normalizeCategoryData(key, digestData[key]); return categoryData && categoryData.count > 0; }); - if (!hasItems) { - return null; - } - const renderEmailContent = (item: DigestItem) => { if (!item.content) return null; @@ -343,21 +339,32 @@ export default function DigestEmail(props: DigestEmailProps) { - {Object.keys(digestData).map((categoryKey) => { - const categoryData = normalizeCategoryData( - categoryKey, - digestData[categoryKey], - ); - if (!categoryData) return null; - - return ( - - ); - })} + {hasItems ? ( + Object.keys(digestData).map((categoryKey) => { + const categoryData = normalizeCategoryData( + categoryKey, + digestData[categoryKey], + ); + if (!categoryData) return null; + + return ( + + ); + }) + ) : ( +
+ + No emails to summarize in this digest. + + + We'll send you a summary when there are emails to report. + +
+ )}