diff --git a/apps/web/__tests__/ai-calendar-availability.test.ts b/apps/web/__tests__/ai-calendar-availability.test.ts new file mode 100644 index 0000000000..218a6a2bd1 --- /dev/null +++ b/apps/web/__tests__/ai-calendar-availability.test.ts @@ -0,0 +1,602 @@ +/** biome-ignore-all lint/style/noMagicNumbers: test */ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { aiGetCalendarAvailability } from "@/utils/ai/calendar/availability"; +import type { EmailForLLM } from "@/utils/types"; +import { getEmailAccount } from "@/__tests__/helpers"; +import type { BusyPeriod } from "@/utils/calendar/availability"; +import type { Prisma } from "@prisma/client"; + +// Run with: pnpm test-ai calendar-availability + +vi.mock("server-only", () => ({})); + +const TIMEOUT = 15_000; + +// Skip tests unless explicitly running AI tests +const isAiTest = process.env.RUN_AI_TESTS === "true"; + +type CalendarConnectionWithCalendars = Prisma.CalendarConnectionGetPayload<{ + include: { + calendars: { + select: { + calendarId: true; + timezone: true; + primary: true; + }; + }; + }; +}>; + +// Mock the calendar availability function +vi.mock("@/utils/calendar/availability", () => ({ + getCalendarAvailability: vi.fn(), +})); + +// Mock Prisma +vi.mock("@/utils/prisma", () => ({ + default: { + calendarConnection: { + findMany: vi.fn(), + }, + }, +})); + +function getMockEmailForLLM(overrides = {}): EmailForLLM { + return { + id: "msg1", + from: "sender@test.com", + subject: "Meeting Request", + content: "Let's schedule a meeting to discuss the project.", + date: new Date("2024-03-20T10:00:00Z"), + to: "user@test.com", + ...overrides, + }; +} + +function getSchedulingMessages() { + return [ + getMockEmailForLLM({ + id: "msg1", + subject: "Meeting Request - Project Discussion", + content: + "Hi, I'd like to schedule a meeting with you to discuss the upcoming project. Are you available next Tuesday or Wednesday afternoon?", + from: "client@example.com", + }), + getMockEmailForLLM({ + id: "msg2", + subject: "Re: Meeting Request - Project Discussion", + content: + "Thanks for reaching out! I'm generally available in the afternoons. What time works best for you?", + from: "user@test.com", + }), + ]; +} + +function getNonSchedulingMessages() { + return [ + getMockEmailForLLM({ + id: "msg1", + subject: "Project Update", + content: + "Here's the latest update on the project status. Everything is progressing well.", + from: "team@example.com", + }), + ]; +} + +function getMockCalendarConnections(): CalendarConnectionWithCalendars[] { + return [ + { + id: "conn1", + createdAt: new Date(), + updatedAt: new Date(), + provider: "google", + email: "user@test.com", + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: new Date(Date.now() + 3_600_000), // 1 hour from now + isConnected: true, + emailAccountId: "email-account-id", + calendars: [ + { calendarId: "primary", timezone: null, primary: false }, + { calendarId: "work@example.com", timezone: null, primary: false }, + ], + }, + ]; +} + +function getMockCalendarConnectionsWithTimezone( + timezone: string, +): CalendarConnectionWithCalendars[] { + return [ + { + id: "conn1", + createdAt: new Date(), + updatedAt: new Date(), + provider: "google", + email: "user@test.com", + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: new Date(Date.now() + 3_600_000), + isConnected: true, + emailAccountId: "email-account-id", + calendars: [ + { calendarId: "primary", timezone, primary: true }, + { calendarId: "work@example.com", timezone: "UTC", primary: false }, + ], + }, + ]; +} + +function getMockBusyPeriods(): BusyPeriod[] { + return [ + { + start: "2024-03-26T14:00:00Z", + end: "2024-03-26T15:00:00Z", + }, + { + start: "2024-03-27T10:00:00Z", + end: "2024-03-27T11:30:00Z", + }, + ]; +} + +describe.runIf(isAiTest)("aiGetCalendarAvailability", () => { + beforeEach(async () => { + vi.clearAllMocks(); + + // Setup default mocks + const prisma = (await import("@/utils/prisma")).default; + vi.mocked(prisma.calendarConnection.findMany).mockResolvedValue( + getMockCalendarConnections(), + ); + + const { getCalendarAvailability } = vi.mocked( + await import("@/utils/calendar/availability"), + ); + getCalendarAvailability.mockResolvedValue(getMockBusyPeriods()); + }); + + test( + "successfully analyzes scheduling-related email and returns suggested times", + async () => { + const messages = getSchedulingMessages(); + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + expect(Array.isArray(result.suggestedTimes)).toBe(true); + expect(result.suggestedTimes.length).toBeGreaterThan(0); + + // Check that suggested times are in correct format (YYYY-MM-DD HH:MM) + result.suggestedTimes.forEach((time) => { + expect(time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); + + console.debug("Generated suggested times:", result.suggestedTimes); + } + }, + TIMEOUT, + ); + + test("returns null for non-scheduling related emails", async () => { + const messages = getNonSchedulingMessages(); + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + // For non-scheduling emails, the AI should not return suggested times + expect(result).toBeNull(); + }); + + test("handles empty messages array", async () => { + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages: [], + }); + + expect(result).toBeNull(); + }); + + test("handles messages with no content", async () => { + const messages = [ + getMockEmailForLLM({ + subject: "", + content: "", + }), + ]; + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeNull(); + }); + + test( + "works with specific date and time mentions", + async () => { + const messages = [ + getMockEmailForLLM({ + subject: "Meeting Tomorrow", + content: + "Can we meet tomorrow at 2 PM? I'm also free on Friday at 10 AM if that works better.", + from: "client@example.com", + }), + ]; + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + expect(result.suggestedTimes.length).toBeGreaterThan(0); + console.debug("Specific time suggestions:", result.suggestedTimes); + } + }, + TIMEOUT, + ); + + test( + "handles calendar availability conflicts", + async () => { + // Mock busy periods that conflict with requested times + const { getCalendarAvailability } = vi.mocked( + await import("@/utils/calendar/availability"), + ); + getCalendarAvailability.mockResolvedValue([ + { + start: "2024-03-26T14:00:00Z", // Busy during requested time + end: "2024-03-26T16:00:00Z", + }, + ]); + + const messages = [ + getMockEmailForLLM({ + subject: "Meeting Request", + content: "Are you available Tuesday at 2 PM for a quick meeting?", + from: "client@example.com", + }), + ]; + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + // The AI should suggest alternative times when the requested time is busy + expect(result.suggestedTimes.length).toBeGreaterThan(0); + console.debug("Alternative time suggestions:", result.suggestedTimes); + } + }, + TIMEOUT, + ); + + test("handles no calendar connections", async () => { + // Mock no calendar connections + const prisma = (await import("@/utils/prisma")).default; + vi.mocked(prisma.calendarConnection.findMany).mockResolvedValue([]); + + const messages = getSchedulingMessages(); + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + // Should still work even without calendar connections + // The AI can still suggest times based on the email content + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + console.debug("Suggestions without calendar:", result.suggestedTimes); + } + }); + + test( + "works with user context and about information", + async () => { + const messages = getSchedulingMessages(); + const emailAccount = getEmailAccount({ + about: + "I'm a software engineer who prefers morning meetings and works in PST timezone.", + }); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + expect(result.suggestedTimes.length).toBeGreaterThan(0); + console.debug("Context-aware suggestions:", result.suggestedTimes); + } + }, + TIMEOUT, + ); + + test( + "handles multiple calendar connections", + async () => { + // Mock multiple calendar connections + const prisma = (await import("@/utils/prisma")).default; + const multipleConnections: CalendarConnectionWithCalendars[] = [ + { + id: "conn1", + createdAt: new Date(), + updatedAt: new Date(), + provider: "google", + email: "user@test.com", + emailAccountId: "email-account-id", + isConnected: true, + accessToken: "access-token-1", + refreshToken: "refresh-token-1", + expiresAt: new Date(Date.now() + 3_600_000), + calendars: [ + { calendarId: "primary", timezone: null, primary: false }, + ], + }, + { + id: "conn2", + createdAt: new Date(), + updatedAt: new Date(), + provider: "google", + email: "work@example.com", + emailAccountId: "email-account-id", + isConnected: true, + accessToken: "access-token-2", + refreshToken: "refresh-token-2", + expiresAt: new Date(Date.now() + 3_600_000), + calendars: [ + { calendarId: "work@example.com", timezone: null, primary: false }, + ], + }, + ]; + vi.mocked(prisma.calendarConnection.findMany).mockResolvedValue( + multipleConnections, + ); + + const messages = getSchedulingMessages(); + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + expect(result.suggestedTimes.length).toBeGreaterThan(0); + console.debug("Multi-calendar suggestions:", result.suggestedTimes); + } + }, + TIMEOUT, + ); + + test( + "handles timezone-aware scheduling with EST timezone", + async () => { + // Mock calendar connections with EST timezone + const prisma = (await import("@/utils/prisma")).default; + vi.mocked(prisma.calendarConnection.findMany).mockResolvedValue( + getMockCalendarConnectionsWithTimezone("America/New_York"), + ); + + const messages = [ + getMockEmailForLLM({ + subject: "Meeting Request - EST timezone", + content: + "Can we meet tomorrow at 2 PM EST? I'm available in the afternoon.", + from: "client@example.com", + }), + ]; + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + expect(result.suggestedTimes.length).toBeGreaterThan(0); + console.debug("EST timezone suggestions:", result.suggestedTimes); + } + + // Verify that getCalendarAvailability was called with the correct timezone + const { getCalendarAvailability } = vi.mocked( + await import("@/utils/calendar/availability"), + ); + expect(getCalendarAvailability).toHaveBeenCalledWith( + expect.objectContaining({ + timezone: "America/New_York", + }), + ); + }, + TIMEOUT, + ); + + test( + "handles timezone-aware scheduling with PST timezone", + async () => { + // Mock calendar connections with PST timezone + const prisma = (await import("@/utils/prisma")).default; + vi.mocked(prisma.calendarConnection.findMany).mockResolvedValue( + getMockCalendarConnectionsWithTimezone("America/Los_Angeles"), + ); + + const messages = [ + getMockEmailForLLM({ + subject: "Meeting Request - PST timezone", + content: + "Are you free for a call at 6 PM Pacific time? Let me know what works best.", + from: "client@example.com", + }), + ]; + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + expect(result.suggestedTimes.length).toBeGreaterThan(0); + console.debug("PST timezone suggestions:", result.suggestedTimes); + } + + // Verify that getCalendarAvailability was called with the correct timezone + const { getCalendarAvailability } = vi.mocked( + await import("@/utils/calendar/availability"), + ); + expect(getCalendarAvailability).toHaveBeenCalledWith( + expect.objectContaining({ + timezone: "America/Los_Angeles", + }), + ); + }, + TIMEOUT, + ); + + test( + "falls back to UTC when no timezone information is available", + async () => { + // Mock calendar connections without timezone information + const prisma = (await import("@/utils/prisma")).default; + vi.mocked(prisma.calendarConnection.findMany).mockResolvedValue([ + { + id: "conn1", + createdAt: new Date(), + updatedAt: new Date(), + provider: "google", + email: "user@test.com", + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: new Date(Date.now() + 3_600_000), + isConnected: true, + emailAccountId: "email-account-id", + calendars: [ + { calendarId: "primary", timezone: null, primary: false }, + ], + } as CalendarConnectionWithCalendars, + ]); + + const messages = getSchedulingMessages(); + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + console.debug("UTC fallback suggestions:", result.suggestedTimes); + } + + // Verify that getCalendarAvailability was called with UTC timezone + const { getCalendarAvailability } = vi.mocked( + await import("@/utils/calendar/availability"), + ); + expect(getCalendarAvailability).toHaveBeenCalledWith( + expect.objectContaining({ + timezone: "UTC", + }), + ); + }, + TIMEOUT, + ); + + test( + "uses primary calendar timezone when multiple calendars have different timezones", + async () => { + // Mock calendar connections with mixed timezones, primary calendar has EST + const prisma = (await import("@/utils/prisma")).default; + vi.mocked(prisma.calendarConnection.findMany).mockResolvedValue([ + { + id: "conn1", + createdAt: new Date(), + updatedAt: new Date(), + provider: "google", + email: "user@test.com", + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: new Date(Date.now() + 3_600_000), + isConnected: true, + emailAccountId: "email-account-id", + calendars: [ + { + calendarId: "primary", + timezone: "America/New_York", + primary: true, + }, + { + calendarId: "work@example.com", + timezone: "America/Los_Angeles", + primary: false, + }, + { + calendarId: "personal@example.com", + timezone: "Europe/London", + primary: false, + }, + ], + } as CalendarConnectionWithCalendars, + ]); + + const messages = getSchedulingMessages(); + const emailAccount = getEmailAccount(); + + const result = await aiGetCalendarAvailability({ + emailAccount, + messages, + }); + + expect(result).toBeDefined(); + if (result) { + expect(result.suggestedTimes).toBeDefined(); + console.debug("Primary timezone suggestions:", result.suggestedTimes); + } + + // Verify that getCalendarAvailability was called with the primary calendar's timezone + const { getCalendarAvailability } = vi.mocked( + await import("@/utils/calendar/availability"), + ); + expect(getCalendarAvailability).toHaveBeenCalledWith( + expect.objectContaining({ + timezone: "America/New_York", + }), + ); + }, + TIMEOUT, + ); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 33c82df0a7..87bd7f6f00 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@ai-sdk/react": "2.0.28", "@asteasolutions/zod-to-openapi": "8.1.0", "@better-auth/sso": "1.3.7", + "@date-fns/tz": "1.4.1", "@dub/analytics": "0.0.30", "@formkit/auto-animate": "0.8.4", "@googleapis/calendar": "^11.0.1", diff --git a/apps/web/utils/ai/calendar/availability.ts b/apps/web/utils/ai/calendar/availability.ts index ecdb5d00d5..1c0522f0e2 100644 --- a/apps/web/utils/ai/calendar/availability.ts +++ b/apps/web/utils/ai/calendar/availability.ts @@ -46,11 +46,20 @@ export async function aiGetCalendarAvailability({ include: { calendars: { where: { isEnabled: true }, - select: { calendarId: true }, + select: { + calendarId: true, + timezone: true, + primary: true, + }, }, }, }); + // Determine user's primary timezone from calendars + const userTimezone = getUserTimezone(calendarConnections); + + logger.trace("Determined user timezone", { userTimezone }); + const system = `You are an AI assistant that analyzes email threads to determine if they contain meeting or scheduling requests, and if yes, returns the suggested times for the meeting. Your task is to: @@ -63,7 +72,9 @@ If the email thread is not about scheduling, return isRelevant: false. You can only call "returnSuggestedTimes" once. Your suggested times should be in the format of "YYYY-MM-DD HH:MM". -IMPORTANT: Another agent is responsible for drafting the final email reply. You just need to reply with the suggested times.`; +IMPORTANT: Another agent is responsible for drafting the final email reply. You just need to reply with the suggested times. + +TIMEZONE CONTEXT: The user's primary timezone is ${userTimezone}. When interpreting times mentioned in emails (like "6pm"), assume they refer to this timezone unless explicitly stated otherwise.`; const prompt = ` ${ @@ -137,6 +148,11 @@ ${threadContent} calendarIds, startDate, endDate, + timezone: userTimezone, + }); + + logger.trace("Calendar availability data", { + availabilityData, }); return availabilityData; @@ -163,3 +179,33 @@ ${threadContent} return result ? { suggestedTimes: result } : null; } + +function getUserTimezone( + calendarConnections: Array<{ + calendars: Array<{ + calendarId: string; + timezone: string | null; + primary: boolean; + }>; + }>, +): string { + // First, try to find the primary calendar's timezone + for (const connection of calendarConnections) { + const primaryCalendar = connection.calendars.find((cal) => cal.primary); + if (primaryCalendar?.timezone) { + return primaryCalendar.timezone; + } + } + + // If no primary calendar found, find any calendar with a timezone + for (const connection of calendarConnections) { + for (const calendar of connection.calendars) { + if (calendar.timezone) { + return calendar.timezone; + } + } + } + + // Fallback to UTC if no timezone information is available + return "UTC"; +} diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 847d1c2bb1..22f8f7c4c0 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -20,6 +20,7 @@ import { createScopedLogger } from "@/utils/logger"; import type { MatchReason } from "@/utils/ai/choose-rule/types"; import { sanitizeActionFields } from "@/utils/action-item"; import { extractEmailAddress } from "@/utils/email"; +import { filterNullProperties } from "@/utils"; import { analyzeSenderPattern } from "@/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api"; import { scheduleDelayedActions, @@ -69,7 +70,9 @@ export async function runRules({ emailAccountId: emailAccount.id, }); - logger.trace("Matching rule", { result }); + logger.trace("Matching rule", () => ({ + result: filterNullProperties(result), + })); if (result.rule) { return await executeMatchedRule( diff --git a/apps/web/utils/ai/rule/create-prompt-from-rule.ts b/apps/web/utils/ai/rule/create-prompt-from-rule.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/web/utils/calendar/availability.ts b/apps/web/utils/calendar/availability.ts index a5e1ec1283..61f1af495a 100644 --- a/apps/web/utils/calendar/availability.ts +++ b/apps/web/utils/calendar/availability.ts @@ -1,4 +1,5 @@ import type { calendar_v3 } from "@googleapis/calendar"; +import { TZDate } from "@date-fns/tz"; import { getCalendarClientWithRefresh } from "./client"; import { createScopedLogger } from "@/utils/logger"; import { startOfDay, endOfDay } from "date-fns"; @@ -49,6 +50,8 @@ async function fetchCalendarBusyPeriods({ } } + logger.trace("Calendar busy periods", { busyPeriods, timeMin, timeMax }); + return busyPeriods; } catch (error) { logger.error("Error fetching calendar busy periods", { error }); @@ -64,6 +67,7 @@ export async function getCalendarAvailability({ calendarIds, startDate, endDate, + timezone = "UTC", }: { accessToken?: string | null; refreshToken: string | null; @@ -72,6 +76,7 @@ export async function getCalendarAvailability({ calendarIds: string[]; startDate: Date; endDate: Date; + timezone?: string; }): Promise { const calendarClient = await getCalendarClientWithRefresh({ accessToken, @@ -80,8 +85,20 @@ export async function getCalendarAvailability({ emailAccountId, }); - const timeMin = startOfDay(startDate).toISOString(); - const timeMax = endOfDay(endDate).toISOString(); + // Compute day boundaries directly in the user's timezone using TZDate + const startDateInTZ = new TZDate(startDate, timezone); + const endDateInTZ = new TZDate(endDate, timezone); + + const timeMin = startOfDay(startDateInTZ).toISOString(); + const timeMax = endOfDay(endDateInTZ).toISOString(); + + logger.trace("Calendar availability request with timezone", { + timezone, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + timeMin, + timeMax, + }); return await fetchCalendarBusyPeriods({ calendarClient, diff --git a/apps/web/utils/logger.ts b/apps/web/utils/logger.ts index 91be5291dd..e7b20f702f 100644 --- a/apps/web/utils/logger.ts +++ b/apps/web/utils/logger.ts @@ -53,10 +53,15 @@ export function createScopedLogger(scope: string) { console.error(formatMessage("error", message, args)), warn: (message: string, ...args: unknown[]) => console.warn(formatMessage("warn", message, args)), - trace: (message: string, ...args: unknown[]) => { - if (env.ENABLE_DEBUG_LOGS) { - console.log(formatMessage("trace", message, args)); - } + trace: ( + message: string, + ...args: Array | [() => unknown] | [() => unknown[]] + ) => { + if (!env.ENABLE_DEBUG_LOGS) return; + const first = args[0]; + const resolved = typeof first === "function" ? first() : args; + const finalArgs = Array.isArray(resolved) ? resolved : [resolved]; + console.log(formatMessage("trace", message, finalArgs)); }, with: (newFields: Record) => createLogger({ ...fields, ...newFields }), @@ -74,10 +79,13 @@ function createAxiomLogger(scope: string) { log.error(message, { scope, ...fields, ...formatError(args) }), warn: (message: string, args?: Record) => log.warn(message, { scope, ...fields, ...args }), - trace: (message: string, args?: Record) => { - if (env.ENABLE_DEBUG_LOGS) { - log.debug(message, { scope, ...fields, ...args }); - } + trace: ( + message: string, + args?: Record | (() => Record), + ) => { + if (!env.ENABLE_DEBUG_LOGS) return; + const resolved = typeof args === "function" ? args() : args; + log.debug(message, { scope, ...fields, ...resolved }); }, with: (newFields: Record) => createLogger({ ...fields, ...newFields }), diff --git a/apps/web/utils/reply-tracker/generate-draft.ts b/apps/web/utils/reply-tracker/generate-draft.ts index 408650b4ab..9f2c2395a2 100644 --- a/apps/web/utils/reply-tracker/generate-draft.ts +++ b/apps/web/utils/reply-tracker/generate-draft.ts @@ -3,7 +3,7 @@ import { internalDateToDate } from "@/utils/date"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import { aiDraftWithKnowledge } from "@/utils/ai/reply/draft-with-knowledge"; import { getReply, saveReply } from "@/utils/redis/reply"; -import { getEmailAccountWithAi, getWritingStyle } from "@/utils/user/get"; +import { getWritingStyle } from "@/utils/user/get"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import prisma from "@/utils/prisma"; @@ -19,45 +19,6 @@ import { env } from "@/env"; const logger = createScopedLogger("generate-reply"); -export async function generateDraft({ - emailAccountId, - client, - message, -}: { - emailAccountId: string; - client: EmailProvider; - message: ParsedMessage; -}) { - const logger = createScopedLogger("generate-reply").with({ - emailAccountId, - messageId: message.id, - threadId: message.threadId, - }); - - logger.info("Generating draft"); - - const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - if (!emailAccount) throw new Error("User not found"); - - // 1. Draft with AI - const result = await fetchMessagesAndGenerateDraft( - emailAccount, - message.threadId, - client, - ); - - logger.info("Draft generated", { result }); - - if (typeof result !== "string") { - throw new Error("Draft result is not a string"); - } - - // 2. Create draft - await client.draftEmail(message, { content: result }, emailAccount.email); - - logger.info("Draft created"); -} - /** * Fetches thread messages and generates draft content in one step */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd830fa409..aa782759a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: '@better-auth/sso': specifier: 1.3.7 version: 1.3.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(zod@3.25.46) + '@date-fns/tz': + specifier: 1.4.1 + version: 1.4.1 '@dub/analytics': specifier: 0.0.30 version: 0.0.30 @@ -16227,7 +16230,7 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.4(@types/node@24.3.0))': + '@vitest/mocker@3.2.4(vite@7.1.4(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 @@ -23404,7 +23407,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.4(@types/node@24.3.0)) + '@vitest/mocker': 3.2.4(vite@7.1.4(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4