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
104 changes: 52 additions & 52 deletions apps/web/app/api/ai/digest/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });

Comment on lines +19 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Return 400 on invalid body; avoid throwing on parse.

Use safeParse and a consistent error payload per API guidelines.

-      const body = digestBody.parse(await request.json());
-      const { emailAccountId, coldEmailId, actionId, message } = body;
+      const json = await request.json();
+      const parsed = digestBody.safeParse(json);
+      if (!parsed.success) {
+        logger.error("Invalid request body", { error: parsed.error });
+        return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
+      }
+      const { emailAccountId, coldEmailId, actionId, message } = parsed.data;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const body = digestBody.parse(await request.json());
const { emailAccountId, coldEmailId, actionId, message } = body;
logger.with({ emailAccountId, messageId: message.id });
const json = await request.json();
const parsed = digestBody.safeParse(json);
if (!parsed.success) {
logger.error("Invalid request body", { error: parsed.error });
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { emailAccountId, coldEmailId, actionId, message } = parsed.data;
logger.with({ emailAccountId, messageId: message.id });
🤖 Prompt for AI Agents
In apps/web/app/api/ai/digest/route.ts around lines 19–23, replace
digestBody.parse(...) with digestBody.safeParse(await request.json()) and branch
on the result; if safeParse returns success: true, proceed and extract {
emailAccountId, coldEmailId, actionId, message } and call logger.with(...). If
success is false, return a 400 response using the API’s standard error payload
(e.g., { error: { type: "validation", message: "<validation summary or first
error>" } }) and avoid throwing so the endpoint consistently returns a JSON 400
for invalid bodies.

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<string> {
if (!actionId) return RuleName.ColdEmail;
Expand Down Expand Up @@ -113,7 +162,7 @@ async function upsertDigest({
emailAccountId: string;
actionId?: string;
coldEmailId?: string;
content: DigestEmailSummarySchema;
content: StoredDigestContent;
}) {
const logger = createScopedLogger("digest").with({
messageId,
Expand Down Expand Up @@ -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 });
}
}),
);
2 changes: 1 addition & 1 deletion apps/web/app/api/clean/gmail/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/clean/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
103 changes: 56 additions & 47 deletions apps/web/app/api/resend/digest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
}: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
);
}
}),
);
23 changes: 5 additions & 18 deletions apps/web/app/api/resend/digest/validation.ts
Original file line number Diff line number Diff line change
@@ -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<typeof digestEmailSummarySchema>;
export const storedDigestContentSchema = z.object({ content: z.string() });
export type StoredDigestContent = z.infer<typeof storedDigestContentSchema>;

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() });

Expand Down
14 changes: 8 additions & 6 deletions apps/web/utils/ai/digest/summarize-email-for-digest.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
);
Expand Down
9 changes: 4 additions & 5 deletions apps/web/utils/ai/digest/summarize-email-for-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof schema>;
const schema = z.object({
content: z.string().describe("The content of the summary text"),
});
type AISummarizeResult = z.infer<typeof schema>;

export async function aiSummarizeEmailForDigest({
ruleName,
Expand Down
Loading
Loading