diff --git a/.cursor/rules/llm.mdc b/.cursor/rules/llm.mdc index c0350176fd..e188052eda 100644 --- a/.cursor/rules/llm.mdc +++ b/.cursor/rules/llm.mdc @@ -27,11 +27,12 @@ Follow this standard structure for LLM-related functions: import { z } from "zod"; import { createScopedLogger } from "@/utils/logger"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserEmailWithAI } from "@/utils/llms/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import { createGenerateObject } from "@/utils/llms"; export async function featureFunction(options: { inputData: InputType; - user: UserEmailWithAI; + emailAccount: EmailAccountWithAI; }) { const { inputData, user } = options; @@ -48,10 +49,23 @@ export async function featureFunction(options: { ... -${user.about ? `${user.about}` : ""}`; +${emailAccount.about ? `${emailAccount.about}` : ""}`; - const result = await chatCompletionObject({ - userAi: user, + // 7. Log inputs + logger.trace("Input", { system, prompt }); + + // 8. Set up and call LLM + const modelOptions = getModel(emailAccount.user); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "Feature Name", + modelOptions, + }); + + + const result = await generateObject({ + ...modelOptions, system, prompt, schema: z.object({ @@ -62,11 +76,13 @@ ${user.about ? `${user.about}` : ""}`; }), array_field: z.array(z.string()), }), - userEmail: user.email, - usageLabel: "Feature Name", }); -return result.object; + // 9. Log outputs + logger.trace("Output", result.object); + + // 10. Return validated result + return result.object; } ``` diff --git a/apps/web/app/(app)/[emailAccountId]/debug/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/page.tsx index ca483878bc..f2daf5e549 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/page.tsx @@ -26,6 +26,9 @@ export default async function DebugPage(props: { Rule History + ); diff --git a/apps/web/app/(app)/[emailAccountId]/debug/report/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/report/page.tsx new file mode 100644 index 0000000000..aea810fe1f --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/debug/report/page.tsx @@ -0,0 +1,624 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAction } from "next-safe-action/hooks"; +import { Badge } from "@/components/ui/badge"; +import { + Mail, + TrendingUp, + Target, + Zap, + CheckCircle, + Clock, +} from "lucide-react"; +import { useParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { LoadingContent } from "@/components/LoadingContent"; +import { + type EmailReportData, + generateReportAction, +} from "@/utils/actions/report"; +import { useState } from "react"; +import { toastError, toastSuccess } from "@/components/Toast"; + +export default function EmailReportPage() { + const params = useParams(); + const emailAccountId = params.emailAccountId; + + if (typeof emailAccountId !== "string") + throw new Error("Email account ID is required"); + + const [report, setReport] = useState(null); + + const { executeAsync, isExecuting, result } = useAction( + generateReportAction.bind(null, emailAccountId), + { + onSuccess: () => { + if (result?.data) { + setReport(result.data); + toastSuccess({ description: "Report generated successfully" }); + } else { + toastError({ description: "Failed to generate report" }); + } + }, + onError: (result) => { + toastError({ + title: "Failed to generate report", + description: result.error.serverError || "Unknown error", + }); + }, + }, + ); + + return ( +
+ + + + + Email Report + + + + + + +

+ Comprehensive analysis of your email patterns and personalized + recommendations. +

+ + {/* Report Display */} + {report && ( +
+ {/* Executive Summary */} + + + + + Executive Summary + + + +
+
+

+ Professional Persona +

+

+ {report.executiveSummary?.userProfile.persona} +

+

+ Confidence:{" "} + {report.executiveSummary?.userProfile.confidence}% +

+
+
+

+ Email Sources +

+
+
+ Inbox: + + {report.emailActivityOverview.dataSources.inbox} + +
+
+ Archived: + + { + report.emailActivityOverview.dataSources + .archived + } + +
+
+ Sent: + + {report.emailActivityOverview.dataSources.sent} + +
+
+
+
+

+ Quick Actions +

+
+ {report.executiveSummary?.quickActions + .slice(0, 3) + .map((action, index) => ( +
+ + {action.difficulty} + + + {action.action} + +
+ ))} +
+
+
+ +
+

+ Top Insights +

+
+ {report.executiveSummary?.topInsights.map( + (insight, index) => ( +
+ {insight.icon} +
+
+ + {insight.priority} + +
+

+ {insight.insight} +

+
+
+ ), + )} +
+
+
+
+ + {/* User Persona */} + + + + + Professional Identity + + + +
+

+ Professional Identity +

+

+ {report.userPersona?.professionalIdentity.persona} +

+
+ {report.userPersona?.professionalIdentity.supportingEvidence.map( + (evidence, index) => ( +

+ + {evidence} +

+ ), + )} +
+
+ +
+

+ Current Priorities +

+
+ {report.userPersona?.currentPriorities.map( + (priority, index) => ( + + {priority} + + ), + )} +
+
+
+
+ + {/* Email Behavior */} + + + + + Email Behavior Patterns + + + +
+
+
+ Timing Patterns +
+

+ Peak hours:{" "} + {report.emailBehavior?.timingPatterns.peakHours.join( + ", ", + )} +

+

+ Response preference:{" "} + { + report.emailBehavior?.timingPatterns + .responsePreference + } +

+

+ Frequency:{" "} + {report.emailBehavior?.timingPatterns.frequency} +

+
+
+
+ Content Preferences +
+

+ Preferred:{" "} + {report.emailBehavior?.contentPreferences.preferred.join( + ", ", + )} +

+

+ Avoided:{" "} + {report.emailBehavior?.contentPreferences.avoided.join( + ", ", + )} +

+
+
+
+ Engagement Triggers +
+
+ {report.emailBehavior?.engagementTriggers.map( + (trigger, index) => ( +

+ • {trigger} +

+ ), + )} +
+
+
+
+
+ + {/* Response Patterns */} + + + + + Response Patterns & Categories + + + +
+

+ Common Response Patterns +

+
+ {report.responsePatterns?.commonResponses.map( + (response, index) => ( +
+
+
+ {response.pattern} +
+ + {response.frequency}% + +
+

+ "{response.example}" +

+
+ {response.triggers.map( + (trigger, triggerIndex) => ( + + {trigger} + + ), + )} +
+
+ ), + )} +
+
+ +
+

+ Email Categories +

+
+ {report.responsePatterns?.categoryOrganization.map( + (category, index) => ( +
+
+
+ {category.category} +
+ + {category.priority} + +
+

+ {category.description} +

+

+ {category.emailCount} emails +

+
+ ), + )} +
+
+
+
+ + {/* Label Analysis */} + + + + + Label Analysis + + + +
+

+ Current Labels +

+
+ {report.labelAnalysis.currentLabels.map( + (label, index) => ( +
+

+ {label.name} +

+

+ {label.emailCount} +

+

+ {label.unreadCount} unread +

+

+ {label.threadCount} threads +

+
+ ), + )} +
+
+ +
+

+ Optimization Suggestions +

+
+ {report.labelAnalysis.optimizationSuggestions.map( + (suggestion, index) => ( +
+
+
+ + {suggestion.type} + +

+ {suggestion.suggestion} +

+
+

+ {suggestion.reason} +

+
+ + {suggestion.impact} impact + +
+ ), + )} +
+
+
+
+ + {/* Actionable Recommendations */} + + + + + Actionable Recommendations + + + +
+

+ Immediate Actions +

+
+ {report.actionableRecommendations?.immediateActions.map( + (action, index) => ( +
+
+

+ {action.action} +

+

+ Time required: {action.timeRequired} +

+
+
+ + {action.difficulty} + + + {action.impact} impact + +
+
+ ), + )} +
+
+ +
+

+ Short-term Improvements +

+
+ {report.actionableRecommendations?.shortTermImprovements.map( + (improvement, index) => ( +
+
+ {improvement.improvement} +
+

+ Timeline: {improvement.timeline} +

+

+ {improvement.expectedBenefit} +

+
+ ), + )} +
+
+ +
+

+ Long-term Strategy +

+
+ {report.actionableRecommendations?.longTermStrategy.map( + (strategy, index) => ( +
+
+ {strategy.strategy} +
+

+ {strategy.description} +

+
+ {strategy.successMetrics.map( + (metric, metricIndex) => ( + + {metric} + + ), + )} +
+
+ ), + )} +
+
+
+
+
+ )} +
+
+
+
+ ); +} + +const getPriorityColor = (priority: "high" | "medium" | "low") => { + switch (priority) { + case "high": + return "bg-red-100 text-red-800"; + case "medium": + return "bg-yellow-100 text-yellow-800"; + case "low": + return "bg-green-100 text-green-800"; + } +}; + +const getDifficultyColor = (difficulty: "easy" | "medium" | "hard") => { + switch (difficulty) { + case "easy": + return "bg-green-100 text-green-800"; + case "medium": + return "bg-yellow-100 text-yellow-800"; + case "hard": + return "bg-red-100 text-red-800"; + } +}; + +const getImpactColor = (impact: "high" | "medium" | "low") => { + switch (impact) { + case "high": + return "bg-blue-100 text-blue-800"; + case "medium": + return "bg-purple-100 text-purple-800"; + case "low": + return "bg-gray-100 text-gray-800"; + } +}; diff --git a/apps/web/app/(landing)/components/test-action.ts b/apps/web/app/(landing)/components/test-action.ts index 10e6b31773..74c2504513 100644 --- a/apps/web/app/(landing)/components/test-action.ts +++ b/apps/web/app/(landing)/components/test-action.ts @@ -1,6 +1,7 @@ "use server"; import { createScopedLogger } from "@/utils/logger"; +import { sleep } from "@/utils/sleep"; const logger = createScopedLogger("testAction"); @@ -8,7 +9,7 @@ export async function testAction() { logger.info("testAction started"); // sleep for 5 seconds - await new Promise((resolve) => setTimeout(resolve, 5000)); + await sleep(5000); logger.info("testAction completed"); diff --git a/apps/web/utils/actions/report.ts b/apps/web/utils/actions/report.ts new file mode 100644 index 0000000000..6f1ea74f3b --- /dev/null +++ b/apps/web/utils/actions/report.ts @@ -0,0 +1,264 @@ +"use server"; + +import type { gmail_v1 } from "@googleapis/gmail"; +import { z } from "zod"; +import { + fetchEmailsForReport, + fetchGmailTemplates, +} from "@/utils/ai/report/fetch"; +import { aiSummarizeEmails } from "@/utils/ai/report/summarize-emails"; +import { aiGenerateExecutiveSummary } from "@/utils/ai/report/generate-executive-summary"; +import { aiBuildUserPersona } from "@/utils/ai/report/build-user-persona"; +import { aiAnalyzeEmailBehavior } from "@/utils/ai/report/analyze-email-behavior"; +import { aiAnalyzeResponsePatterns } from "@/utils/ai/report/response-patterns"; +import { aiAnalyzeLabelOptimization } from "@/utils/ai/report/analyze-label-optimization"; +import { aiGenerateActionableRecommendations } from "@/utils/ai/report/generate-actionable-recommendations"; +import { createScopedLogger } from "@/utils/logger"; +import { actionClient } from "@/utils/actions/safe-action"; +import { getEmailAccountWithAi } from "@/utils/user/get"; +import { getGmailClientForEmail } from "@/utils/account"; +import { getEmailForLLM } from "@/utils/get-email-from-message"; + +const logger = createScopedLogger("actions/report"); + +export type EmailReportData = Awaited>; + +export const generateReportAction = actionClient + .metadata({ name: "generateReport" }) + .schema(z.object({})) + .action(async ({ ctx: { emailAccountId } }) => { + return getEmailReportData({ emailAccountId }); + }); + +async function getEmailReportData({ + emailAccountId, +}: { + emailAccountId: string; +}) { + logger.info("getEmailReportData started", { emailAccountId }); + + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); + + if (!emailAccount) { + logger.error("Email account not found", { emailAccountId }); + throw new Error("Email account not found"); + } + + const { receivedEmails, sentEmails, totalReceived, totalSent } = + await fetchEmailsForReport({ emailAccount }); + + const [receivedSummaries, sentSummaries] = await Promise.all([ + aiSummarizeEmails( + receivedEmails.map((message) => + getEmailForLLM(message, { maxLength: 1000 }), + ), + emailAccount, + ).catch((error) => { + logger.error("Error summarizing received emails", { error }); + return []; + }), + aiSummarizeEmails( + sentEmails.map((message) => getEmailForLLM(message, { maxLength: 1000 })), + emailAccount, + ).catch((error) => { + logger.error("Error summarizing sent emails", { error }); + return []; + }), + ]); + + const gmail = await getGmailClientForEmail({ + emailAccountId: emailAccount.id, + }); + + const gmailLabels = await fetchGmailLabels(gmail); + const gmailSignature = await fetchGmailSignature(gmail); + const gmailTemplates = await fetchGmailTemplates(gmail); + + const [ + executiveSummary, + userPersona, + emailBehavior, + responsePatterns, + labelAnalysis, + ] = await Promise.all([ + aiGenerateExecutiveSummary( + receivedSummaries, + sentSummaries, + gmailLabels, + emailAccount, + ).catch((error) => { + logger.error("Error generating executive summary", { error }); + }), + aiBuildUserPersona( + receivedSummaries, + emailAccount, + sentSummaries, + gmailSignature, + gmailTemplates, + ).catch((error) => { + logger.error("Error generating user persona", { error }); + }), + aiAnalyzeEmailBehavior( + receivedSummaries, + emailAccount, + sentSummaries, + ).catch((error) => { + logger.error("Error generating email behavior", { error }); + }), + aiAnalyzeResponsePatterns( + receivedSummaries, + emailAccount, + sentSummaries, + ).catch((error) => { + logger.error("Error generating response patterns", { error }); + }), + aiAnalyzeLabelOptimization( + receivedSummaries, + emailAccount, + gmailLabels, + ).catch((error) => { + logger.error("Error generating label optimization", { error }); + }), + ]); + + const actionableRecommendations = userPersona + ? await aiGenerateActionableRecommendations( + receivedSummaries, + emailAccount, + userPersona, + ).catch((error) => { + logger.error("Error generating actionable recommendations", { error }); + }) + : null; + + return { + executiveSummary, + emailActivityOverview: { + dataSources: { + inbox: totalReceived, + archived: 0, + trash: 0, + sent: totalSent, + }, + }, + userPersona, + emailBehavior, + responsePatterns, + labelAnalysis: { + currentLabels: gmailLabels.map((label) => ({ + name: label.name, + emailCount: label.messagesTotal || 0, + unreadCount: label.messagesUnread || 0, + threadCount: label.threadsTotal || 0, + unreadThreads: label.threadsUnread || 0, + color: label.color || null, + type: label.type, + })), + optimizationSuggestions: labelAnalysis?.optimizationSuggestions || [], + }, + actionableRecommendations, + }; +} + +// TODO: should be able to import this functionality from elsewhere +async function fetchGmailLabels( + gmail: gmail_v1.Gmail, +): Promise { + try { + const response = await gmail.users.labels.list({ userId: "me" }); + + const userLabels = + response.data.labels?.filter( + (label: gmail_v1.Schema$Label) => + label.type === "user" && + label.name && + !label.name.startsWith("CATEGORY_") && + !label.name.startsWith("CHAT"), + ) || []; + + const labelsWithCounts = await Promise.all( + userLabels + .filter( + ( + label, + ): label is gmail_v1.Schema$Label & { id: string; name: string } => + Boolean(label.id && label.name), + ) + .map(async (label) => { + try { + const labelDetail = await gmail.users.labels.get({ + userId: "me", + id: label.id, + }); + return { + ...label, + messagesTotal: labelDetail.data.messagesTotal || 0, + messagesUnread: labelDetail.data.messagesUnread || 0, + threadsTotal: labelDetail.data.threadsTotal || 0, + threadsUnread: labelDetail.data.threadsUnread || 0, + }; + } catch (error) { + logger.warn(`Failed to get details for label ${label.name}:`, { + error: error instanceof Error ? error.message : String(error), + }); + return { + ...label, + messagesTotal: 0, + messagesUnread: 0, + threadsTotal: 0, + threadsUnread: 0, + }; + } + }), + ); + + const sortedLabels = labelsWithCounts.sort( + (a, b) => (b.messagesTotal || 0) - (a.messagesTotal || 0), + ); + + return sortedLabels; + } catch (error) { + logger.warn("Failed to fetch Gmail labels:", { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} + +// TODO: should be able to import this functionality from elsewhere +async function fetchGmailSignature(gmail: gmail_v1.Gmail): Promise { + try { + const sendAsList = await gmail.users.settings.sendAs.list({ + userId: "me", + }); + + if (!sendAsList.data.sendAs || sendAsList.data.sendAs.length === 0) { + logger.warn("No sendAs settings found"); + return ""; + } + + const primarySendAs = sendAsList.data.sendAs[0]; + if (!primarySendAs.sendAsEmail) { + logger.warn("No primary sendAs email found"); + return ""; + } + + const signatureResponse = await gmail.users.settings.sendAs.get({ + userId: "me", + sendAsEmail: primarySendAs.sendAsEmail, + }); + + const signature = signatureResponse.data.signature; + logger.info("Gmail signature fetched successfully", { + hasSignature: !!signature, + sendAsEmail: primarySendAs.sendAsEmail, + }); + + return signature || ""; + } catch (error) { + logger.warn("Failed to fetch Gmail signature:", { + error: error instanceof Error ? error.message : String(error), + }); + return ""; + } +} diff --git a/apps/web/utils/ai/assistant/chat.ts b/apps/web/utils/ai/assistant/chat.ts index 5d83c8c415..e8132df8a2 100644 --- a/apps/web/utils/ai/assistant/chat.ts +++ b/apps/web/utils/ai/assistant/chat.ts @@ -460,9 +460,9 @@ const updateRuleActionsTool = ({ subject: action.fields?.subject ?? null, content: action.fields?.content ?? null, webhookUrl: action.fields?.webhookUrl ?? null, - folderName: isMicrosoftProvider(provider) - ? (action.fields?.folderName ?? null) - : undefined, + ...(isMicrosoftProvider(provider) && { + folderName: action.fields?.folderName ?? null, + }), }, delayInMinutes: action.delayInMinutes ?? null, })), diff --git a/apps/web/utils/ai/knowledge/writing-style.ts b/apps/web/utils/ai/knowledge/writing-style.ts index cc346dd8cd..deda9c55f8 100644 --- a/apps/web/utils/ai/knowledge/writing-style.ts +++ b/apps/web/utils/ai/knowledge/writing-style.ts @@ -89,6 +89,7 @@ ${ examples: z.array(z.string()), }), }); + logger.trace("Output", result.object); return result.object; } diff --git a/apps/web/utils/ai/report/analyze-email-behavior.ts b/apps/web/utils/ai/report/analyze-email-behavior.ts new file mode 100644 index 0000000000..dbdc9f6518 --- /dev/null +++ b/apps/web/utils/ai/report/analyze-email-behavior.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { createGenerateObject } from "@/utils/llms"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import type { EmailSummary } from "@/utils/ai/report/summarize-emails"; +import { createScopedLogger } from "@/utils/logger"; +import { getModel } from "@/utils/llms/model"; + +const logger = createScopedLogger("email-report-email-behavior"); + +const emailBehaviorSchema = z.object({ + timingPatterns: z.object({ + peakHours: z.array(z.string()).describe("Peak email activity hours"), + responsePreference: z.string().describe("Preferred response timing"), + frequency: z.string().describe("Overall email frequency"), + }), + contentPreferences: z.object({ + preferred: z + .array(z.string()) + .describe("Types of emails user engages with"), + avoided: z + .array(z.string()) + .describe("Types of emails user typically ignores"), + }), + engagementTriggers: z + .array(z.string()) + .describe("What prompts user to take action on emails"), +}); + +export async function aiAnalyzeEmailBehavior( + emailSummaries: EmailSummary[], + emailAccount: EmailAccountWithAI, + sentEmailSummaries?: EmailSummary[], +) { + const system = `You are an expert AI system that analyzes a user's email behavior to infer timing patterns, content preferences, and automation opportunities. + +Focus on identifying patterns that can be automated and providing specific, actionable automation rules that would save time and improve email management efficiency.`; + + const prompt = `### Email Analysis Data + +**Received Emails:** +${emailSummaries.map((email, i) => `${i + 1}. From: ${email.sender} | Subject: ${email.subject} | Category: ${email.category} | Summary: ${email.summary}`).join("\n")} + +${ + sentEmailSummaries && sentEmailSummaries.length > 0 + ? ` +**Sent Emails:** +${sentEmailSummaries.map((email, i) => `${i + 1}. To: ${email.sender} | Subject: ${email.subject} | Category: ${email.category} | Summary: ${email.summary}`).join("\n")} +` + : "" +} + +--- + +Analyze the email patterns and identify: +1. Timing patterns (when emails are most active, response preferences) +2. Content preferences (what types of emails they engage with vs avoid) +3. Engagement triggers (what prompts them to take action) +4. Specific automation opportunities with estimated time savings`; + + logger.trace("Input", { system, prompt }); + + const modelOptions = getModel(emailAccount.user, "economy"); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "email-report-email-behavior", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system, + prompt, + schema: emailBehaviorSchema, + }); + + logger.trace("Output", result.object); + + return result.object; +} diff --git a/apps/web/utils/ai/report/analyze-label-optimization.ts b/apps/web/utils/ai/report/analyze-label-optimization.ts new file mode 100644 index 0000000000..cf5c8e5962 --- /dev/null +++ b/apps/web/utils/ai/report/analyze-label-optimization.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { createGenerateObject } from "@/utils/llms"; +import type { gmail_v1 } from "@googleapis/gmail"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import type { EmailSummary } from "@/utils/ai/report/summarize-emails"; +import { createScopedLogger } from "@/utils/logger"; +import { getModel } from "@/utils/llms/model"; + +const logger = createScopedLogger("email-report-label-analysis"); + +const labelAnalysisSchema = z.object({ + optimizationSuggestions: z.array( + z.object({ + type: z + .enum(["create", "consolidate", "rename", "delete"]) + .describe("Type of optimization"), + suggestion: z.string().describe("Specific suggestion"), + reason: z.string().describe("Reason for this suggestion"), + impact: z.enum(["high", "medium", "low"]).describe("Expected impact"), + }), + ), +}); + +export async function aiAnalyzeLabelOptimization( + emailSummaries: EmailSummary[], + emailAccount: EmailAccountWithAI, + gmailLabels: gmail_v1.Schema$Label[], +): Promise> { + const system = `You are a Gmail organization expert. Analyze the user's current labels and email patterns to suggest specific optimizations that will improve their email organization and workflow efficiency. + +Focus on practical suggestions that will reduce email management time and improve organization.`; + + const prompt = `### Current Gmail Labels +${gmailLabels.map((label) => `- ${label.name}: ${label.messagesTotal || 0} emails, ${label.messagesUnread || 0} unread`).join("\n")} + +### Email Content Analysis +${emailSummaries + .slice(0, 30) + .map( + (email, i) => + `${i + 1}. From: ${email.sender} | Subject: ${email.subject} | Category: ${email.category} | Summary: ${email.summary}`, + ) + .join("\n")} + +--- + +Based on the current labels and email content, suggest specific optimizations: +1. Labels to create based on email patterns +2. Labels to consolidate that have overlapping purposes +3. Labels to rename for better clarity +4. Labels to delete that are unused or redundant + +Each suggestion should include the reason and expected impact.`; + + logger.trace("Input", { system, prompt }); + + const modelOptions = getModel(emailAccount.user, "economy"); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "email-report-label-analysis", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system, + prompt, + schema: labelAnalysisSchema, + }); + + logger.trace("Output", result.object); + + return result.object; +} diff --git a/apps/web/utils/ai/report/build-user-persona.ts b/apps/web/utils/ai/report/build-user-persona.ts new file mode 100644 index 0000000000..d379857852 --- /dev/null +++ b/apps/web/utils/ai/report/build-user-persona.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; +import { createGenerateObject } from "@/utils/llms"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import type { EmailSummary } from "@/utils/ai/report/summarize-emails"; +import { createScopedLogger } from "@/utils/logger"; +import { getModel } from "@/utils/llms/model"; + +const logger = createScopedLogger("email-report-user-persona"); + +const userPersonaSchema = z.object({ + professionalIdentity: z.object({ + persona: z.string().describe("Professional persona identification"), + supportingEvidence: z + .array(z.string()) + .describe("Evidence supporting this persona identification"), + }), + currentPriorities: z + .array(z.string()) + .describe("Current professional priorities based on email content"), +}); +export type UserPersona = z.infer; + +export async function aiBuildUserPersona( + emailSummaries: EmailSummary[], + emailAccount: EmailAccountWithAI, + sentEmailSummaries?: EmailSummary[], + gmailSignature?: string, + gmailTemplates?: string[], +): Promise> { + const system = `You are a highly skilled AI analyst tasked with generating a focused professional persona of a user based on their email activity. + +Analyze the email summaries, signatures, and templates to identify: +1. Professional identity with supporting evidence +2. Current professional priorities based on email content + +Focus on understanding the user's role and what they're currently focused on professionally.`; + + const prompt = `### Input Data + +**Received Email Summaries:** +${emailSummaries.map((summary, index) => `Email ${index + 1} Summary: ${summary.summary} (Category: ${summary.category})`).join("\n")} + +${ + sentEmailSummaries && sentEmailSummaries.length > 0 + ? ` +**Sent Email Summaries:** +${sentEmailSummaries.map((summary, index) => `Sent ${index + 1} Summary: ${summary.summary} (Category: ${summary.category})`).join("\n")} +` + : "" +} + +**User's Signature:** +${gmailSignature || "[No signature data available – analyze based on email content only]"} + +${ + gmailTemplates && gmailTemplates.length > 0 + ? ` +**User's Gmail Templates:** +${gmailTemplates.map((template, index) => `Template ${index + 1}: ${template}`).join("\n")} +` + : "" +} + +--- + +Analyze the data and identify: +1. **Professional Identity**: What is their role and what evidence supports this? +2. **Current Priorities**: What are they focused on professionally based on email content?`; + + logger.trace("Input", { system, prompt }); + + const modelOptions = getModel(emailAccount.user); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "email-report-user-persona", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system, + prompt, + schema: userPersonaSchema, + }); + + logger.trace("Output", result.object); + + return result.object; +} diff --git a/apps/web/utils/ai/report/fetch.ts b/apps/web/utils/ai/report/fetch.ts new file mode 100644 index 0000000000..2712e9fc5e --- /dev/null +++ b/apps/web/utils/ai/report/fetch.ts @@ -0,0 +1,244 @@ +import type { gmail_v1 } from "@googleapis/gmail"; +import { getMessages, getMessage, parseMessage } from "@/utils/gmail/message"; +import { createScopedLogger } from "@/utils/logger"; +import type { ParsedMessage } from "@/utils/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import { sleep } from "@/utils/sleep"; +import { getGmailClientForEmail } from "@/utils/account"; + +const logger = createScopedLogger("email-report-fetch"); + +/** + * Fetch emails from Gmail based on query + * + * Uses sequential message fetching instead of batch loads to avoid Gmail API rate limits. + * This approach fetches one message at a time with retry and backofflogic, which is slower but more + * reliable than trying to fetch 100 messages at once. + * + * Not usinggetMessagesLargeBatch because it expects the messageIds + * queryBatchMessages is limited to 20 messages at a time + */ +async function fetchEmailsByQuery( + gmail: gmail_v1.Gmail, + query: string, + count: number, +): Promise { + const emails: ParsedMessage[] = []; + let nextPageToken: string | undefined; + let retryCount = 0; + const maxRetries = 3; + + logger.info("fetchEmailsByQuery started", { + query, + targetCount: count, + maxRetries, + }); + + while (emails.length < count && retryCount < maxRetries) { + try { + const response = await getMessages(gmail, { + query: query || undefined, + maxResults: Math.min(100, count - emails.length), + pageToken: nextPageToken, + }); + + if (!response.messages || response.messages.length === 0) { + logger.warn("No messages found, breaking"); + break; + } + + const messagePromises = (response.messages || []).map( + async (message: any, index: number) => { + if (!message.id) { + logger.warn("fetchEmailsByQuery: message without ID", { + index, + message, + }); + return null; + } + + for (let i = 0; i < 3; i++) { + try { + const messageWithPayload = await getMessage( + message.id, + gmail, + "full", + ); + + const parsedMessage = parseMessage(messageWithPayload); + + return parsedMessage; + } catch (error) { + logger.warn("fetchEmailsByQuery: getMessage attempt failed", { + error, + messageId: message.id, + attempt: i + 1, + }); + + if (i === 2) { + logger.warn( + `Failed to fetch message ${message.id} after 3 attempts:`, + { error }, + ); + return null; + } + await sleep(1000 * (i + 1)); + } + } + return null; + }, + ); + + const messages = await Promise.all(messagePromises); + const validMessages = messages.filter((msg) => msg !== null); + + logger.info("fetchEmailsByQuery: message promises completed", { + totalMessages: messages.length, + validMessages: validMessages.length, + nullMessages: messages.length - validMessages.length, + }); + + emails.push(...validMessages); + + nextPageToken = response.nextPageToken || undefined; + if (!nextPageToken) { + break; + } + + retryCount = 0; + } catch (error) { + retryCount++; + logger.error("fetchEmailsByQuery: main loop error", { + error, + retryCount, + maxRetries, + currentEmailsCount: emails.length, + targetCount: count, + }); + + if (retryCount >= maxRetries) { + logger.error(`Failed to fetch emails after ${maxRetries} attempts:`, { + error, + }); + break; + } + + await sleep(2000 * retryCount); + } + } + + logger.info("fetchEmailsByQuery completed", { + finalEmailsCount: emails.length, + targetCount: count, + finalRetryCount: retryCount, + }); + + return emails; +} + +export async function fetchEmailsForReport({ + emailAccount, +}: { + emailAccount: EmailAccountWithAI; +}) { + logger.info("fetchEmailsForReport started", { + emailAccountId: emailAccount.id, + }); + + const gmail = await getGmailClientForEmail({ + emailAccountId: emailAccount.id, + }); + + const receivedEmails = await fetchReceivedEmails(gmail, 200); + await sleep(3000); + const sentEmails = await fetchSentEmails(gmail, 50); + + logger.info("fetchEmailsForReport: preparing return result", { + receivedCount: receivedEmails.length, + sentCount: sentEmails.length, + }); + + return { + receivedEmails, + sentEmails, + totalReceived: receivedEmails.length, + totalSent: sentEmails.length, + }; +} + +async function fetchReceivedEmails( + gmail: gmail_v1.Gmail, + targetCount: number, +): Promise { + const emails: ParsedMessage[] = []; + const sources = [ + { name: "inbox", query: "in:inbox" }, + { name: "archived", query: "-in:inbox -in:sent -in:trash" }, + { name: "trash", query: "in:trash" }, + ]; + + for (const source of sources) { + if (emails.length >= targetCount) break; + + try { + const sourceEmails = await fetchEmailsByQuery( + gmail, + source.query, + targetCount - emails.length, + ); + emails.push(...sourceEmails); + } catch (error) { + logger.error(`Error fetching emails from ${source.name}`, { + error, + query: source.query, + }); + } + } + + return emails; +} + +async function fetchSentEmails( + gmail: gmail_v1.Gmail, + targetCount: number, +): Promise { + try { + const emails = await fetchEmailsByQuery(gmail, "from:me", targetCount); + + return emails; + } catch (error) { + logger.error("Error fetching sent emails", { error }); + return []; + } +} + +export async function fetchGmailTemplates( + gmail: gmail_v1.Gmail, +): Promise { + try { + const drafts = await fetchEmailsByQuery(gmail, "in:draft", 50); + + const templates: string[] = []; + + for (const draft of drafts) { + try { + if (draft.textPlain?.trim()) { + templates.push(draft.textPlain.trim()); + } + + if (templates.length >= 10) break; + } catch (error) { + logger.warn("Failed to process draft:", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return templates; + } catch (error) { + logger.warn("Failed to fetch Gmail templates:", { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} diff --git a/apps/web/utils/ai/report/generate-actionable-recommendations.ts b/apps/web/utils/ai/report/generate-actionable-recommendations.ts new file mode 100644 index 0000000000..52da88e2be --- /dev/null +++ b/apps/web/utils/ai/report/generate-actionable-recommendations.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { createGenerateObject } from "@/utils/llms"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import type { UserPersona } from "@/utils/ai/report/build-user-persona"; +import type { EmailSummary } from "@/utils/ai/report/summarize-emails"; +import { createScopedLogger } from "@/utils/logger"; +import { getModel } from "@/utils/llms/model"; + +const logger = createScopedLogger("email-report-actionable-recommendations"); + +const actionableRecommendationsSchema = z.object({ + immediateActions: z.array( + z.object({ + action: z.string().describe("Specific action to take"), + difficulty: z + .enum(["easy", "medium", "hard"]) + .describe("Implementation difficulty"), + impact: z.enum(["high", "medium", "low"]).describe("Expected impact"), + timeRequired: z.string().describe("Time required (e.g., '5 minutes')"), + }), + ), + shortTermImprovements: z.array( + z.object({ + improvement: z.string().describe("Improvement to implement"), + timeline: z.string().describe("When to implement (e.g., 'This week')"), + expectedBenefit: z.string().describe("Expected benefit"), + }), + ), + longTermStrategy: z.array( + z.object({ + strategy: z.string().describe("Strategic initiative"), + description: z.string().describe("Detailed description"), + successMetrics: z.array(z.string()).describe("How to measure success"), + }), + ), +}); + +export async function aiGenerateActionableRecommendations( + emailSummaries: EmailSummary[], + emailAccount: EmailAccountWithAI, + userPersona: UserPersona, +): Promise> { + const system = `You are an email productivity consultant. Based on the comprehensive email analysis, create specific, actionable recommendations that the user can implement to improve their email workflow. + +Organize recommendations by timeline (immediate, short-term, long-term) and include specific implementation details and expected benefits.`; + + const prompt = `### Analysis Summary + +**User Persona:** ${userPersona.professionalIdentity.persona} +**Current Priorities:** ${userPersona.currentPriorities.join(", ")} +**Email Volume:** ${emailSummaries.length} emails analyzed + +--- + +Create actionable recommendations in three categories: +1. **Immediate Actions** (can be done today): 4-6 specific actions with time requirements +2. **Short-term Improvements** (this week): 3-4 improvements with timelines and benefits +3. **Long-term Strategy** (ongoing): 2-3 strategic initiatives with success metrics + +Focus on practical, implementable solutions that improve email organization and workflow efficiency.`; + + logger.trace("Input", { system, prompt }); + + const modelOptions = getModel(emailAccount.user); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "email-report-actionable-recommendations", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system, + prompt, + schema: actionableRecommendationsSchema, + }); + + logger.trace("Output", result.object); + + return result.object; +} diff --git a/apps/web/utils/ai/report/generate-executive-summary.ts b/apps/web/utils/ai/report/generate-executive-summary.ts new file mode 100644 index 0000000000..7d9ec38cca --- /dev/null +++ b/apps/web/utils/ai/report/generate-executive-summary.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; +import { createGenerateObject } from "@/utils/llms"; +import type { gmail_v1 } from "@googleapis/gmail"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import type { EmailSummary } from "@/utils/ai/report/summarize-emails"; +import { createScopedLogger } from "@/utils/logger"; +import { getModel } from "@/utils/llms/model"; + +const logger = createScopedLogger("email-report-executive-summary"); + +const executiveSummarySchema = z.object({ + userProfile: z.object({ + persona: z + .string() + .describe( + "1-5 word persona identification (e.g., 'Tech Startup Founder')", + ), + confidence: z + .number() + .min(0) + .max(100) + .describe("Confidence level in persona identification (0-100)"), + }), + topInsights: z + .array( + z.object({ + insight: z.string().describe("Key insight about user's email behavior"), + priority: z + .enum(["high", "medium", "low"]) + .describe("Priority level of this insight"), + icon: z.string().describe("Single emoji representing this insight"), + }), + ) + .describe("3-5 most important findings from the analysis"), + quickActions: z + .array( + z.object({ + action: z + .string() + .describe("Specific action the user can take immediately"), + difficulty: z + .enum(["easy", "medium", "hard"]) + .describe("How difficult this action is to implement"), + impact: z + .enum(["high", "medium", "low"]) + .describe("Expected impact of this action"), + }), + ) + .describe("4-6 immediate actions the user can take"), +}); + +export async function aiGenerateExecutiveSummary( + emailSummaries: EmailSummary[], + sentEmailSummaries: EmailSummary[], + gmailLabels: gmail_v1.Schema$Label[], + emailAccount: EmailAccountWithAI, +): Promise> { + const system = `You are a professional persona identification expert. Your primary task is to accurately identify the user's professional role based on their email patterns. + +CRITICAL: The persona must be a specific, recognizable professional role that clearly identifies what this person does for work. + +Examples of GOOD personas: +- "Startup Founder" +- "Software Developer" +- "Real Estate Agent" +- "Marketing Manager" +- "Sales Executive" +- "Product Manager" +- "Consultant" +- "Teacher" +- "Lawyer" +- "Doctor" +- "Influencer" +- "Freelance Designer" + +Examples of BAD personas (too vague): +- "Professional" +- "Business Person" +- "Tech Worker" +- "Knowledge Worker" + +Focus on identifying the PRIMARY professional role based on email content, senders, and communication patterns.`; + + const prompt = `### Email Analysis Data + +**Received Emails (${emailSummaries.length} emails):** +${emailSummaries + .slice(0, 30) + .map( + (email, i) => + `${i + 1}. From: ${email.sender} | Subject: ${email.subject} | Category: ${email.category} | Summary: ${email.summary}`, + ) + .join("\n")} + +**Sent Emails (${sentEmailSummaries.length} emails):** +${sentEmailSummaries + .slice(0, 15) + .map( + (email, i) => + `${i + 1}. To: ${email.sender} | Subject: ${email.subject} | Category: ${email.category} | Summary: ${email.summary}`, + ) + .join("\n")} + +**Current Gmail Labels:** +${gmailLabels.map((label) => `- ${label.name} (${label.messagesTotal || 0} emails)`).join("\n")} + +--- + +**PERSONA IDENTIFICATION INSTRUCTIONS:** + +Analyze the email patterns to identify the user's PRIMARY professional role: + +1. **Look for role indicators:** + - Who do they email? (clients, team members, investors, customers, etc.) + - What topics dominate? (code reviews, property listings, campaign metrics, etc.) + - What language/terminology is used? (technical terms, industry jargon, etc.) + - What responsibilities are evident? (managing teams, closing deals, creating content, etc.) + +2. **Common professional patterns:** + - **Founder/CEO**: Investor emails, team management, strategic decisions, fundraising + - **Developer**: Code reviews, technical discussions, GitHub notifications, deployment issues + - **Sales**: CRM notifications, client outreach, deal discussions, quota tracking + - **Marketing**: Campaign metrics, content creation, social media, analytics + - **Real Estate**: Property listings, client communications, MLS notifications + - **Consultant**: Client projects, proposals, expertise sharing, industry updates + - **Teacher**: Student communications, educational content, institutional emails + +3. **Confidence level:** + - 90-100%: Very clear indicators, consistent patterns + - 70-89%: Strong indicators, some ambiguity + - 50-69%: Mixed signals, multiple possible roles + - Below 50%: Unclear or insufficient data + +Generate: +1. **Specific professional persona** (1-3 words max, e.g., "Software Developer", "Real Estate Agent") +2. **Confidence level** based on clarity of evidence +3. **Top insights** about their email behavior +4. **Quick actions** for immediate improvement`; + + logger.trace("Input", { system, prompt }); + + const modelOptions = getModel(emailAccount.user); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "email-report-executive-summary", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system, + prompt, + schema: executiveSummarySchema, + }); + + logger.trace("Output", result.object); + + return result.object; +} diff --git a/apps/web/utils/ai/report/response-patterns.ts b/apps/web/utils/ai/report/response-patterns.ts new file mode 100644 index 0000000000..a9fa67fb77 --- /dev/null +++ b/apps/web/utils/ai/report/response-patterns.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { createGenerateObject } from "@/utils/llms"; +import type { EmailSummary } from "@/utils/ai/report/summarize-emails"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import { createScopedLogger } from "@/utils/logger"; +import { getModel } from "@/utils/llms/model"; + +const logger = createScopedLogger("email-report-response-patterns"); + +const responsePatternsSchema = z.object({ + commonResponses: z.array( + z.object({ + pattern: z.string().describe("Description of the response pattern"), + example: z.string().describe("Example of this type of response"), + frequency: z + .number() + .describe("Percentage of responses using this pattern"), + triggers: z + .array(z.string()) + .describe("What types of emails trigger this response"), + }), + ), + suggestedTemplates: z.array( + z.object({ + templateName: z.string().describe("Name of the email template"), + template: z.string().describe("The actual email template text"), + useCase: z.string().describe("When to use this template"), + }), + ), + categoryOrganization: z.array( + z.object({ + category: z.string().describe("Email category name"), + description: z + .string() + .describe("What types of emails belong in this category"), + emailCount: z + .number() + .describe("Estimated number of emails in this category"), + priority: z + .enum(["high", "medium", "low"]) + .describe("Priority level for this category"), + }), + ), +}); + +export async function aiAnalyzeResponsePatterns( + emailSummaries: EmailSummary[], + emailAccount: EmailAccountWithAI, + sentEmailSummaries?: EmailSummary[], +) { + const system = `You are an expert email behavior analyst. Your task is to identify common response patterns and suggest email categorization and templates based on the user's email activity. + +Focus on practical, actionable insights for email management including reusable templates and smart categorization. + +IMPORTANT: When creating email categories, avoid meaningless or generic categories such as: +- "Other", "Unknown", "Unclear", "Miscellaneous" +- "Personal" (too generic and meaningless) +- "Unclear Content/HTML Code", "HTML Content", "Raw Content" +- "General", "Random", "Various" + +Only suggest categories that are meaningful and provide clear organizational value. If an email doesn't fit into a meaningful category, don't create a category for it.`; + + const prompt = `### Input Data + +**Received Email Summaries:** +${emailSummaries.map((summary, index) => `Email ${index + 1}: ${summary.summary} (Category: ${summary.category})`).join("\n")} + +${ + sentEmailSummaries && sentEmailSummaries.length > 0 + ? ` +**Sent Email Summaries (User's Response Patterns):** +${sentEmailSummaries.map((summary, index) => `Sent ${index + 1}: ${summary.summary} (Category: ${summary.category})`).join("\n")} +` + : "" +} + +--- + +Analyze the data and identify: +1. Common response patterns the user uses with examples and frequency +2. Suggested email templates that would save time +3. Email categorization strategy with volume estimates and priorities + +For email categorization, create simple, practical categories based on actual email content. Examples of good categories: +- "Work", "Finance", "Meetings", "Marketing", "Support", "Sales" +- "Projects", "Billing", "Team", "Clients", "Products", "Services" +- "Administrative", "Technical", "Legal", "HR", "Operations" + +Only suggest categories that are meaningful and provide clear organizational value. If emails don't fit into meaningful categories, don't create categories for them.`; + + logger.trace("Input", { system, prompt }); + + const modelOptions = getModel(emailAccount.user); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "email-report-response-patterns", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system, + prompt, + schema: responsePatternsSchema, + }); + + logger.trace("Output", result.object); + + return result.object; +} diff --git a/apps/web/utils/ai/report/summarize-emails.ts b/apps/web/utils/ai/report/summarize-emails.ts new file mode 100644 index 0000000000..1a5d340780 --- /dev/null +++ b/apps/web/utils/ai/report/summarize-emails.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; +import { createScopedLogger } from "@/utils/logger"; +import { createGenerateObject } from "@/utils/llms"; +import type { EmailForLLM } from "@/utils/types"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import { sleep } from "@/utils/sleep"; +import { stringifyEmail } from "@/utils/stringify-email"; +import { getModel } from "@/utils/llms/model"; + +const logger = createScopedLogger("email-report-summarize-emails"); + +const emailSummarySchema = z.object({ + summary: z.string().describe("Brief summary of the email content"), + sender: z.string().describe("Email sender"), + subject: z.string().describe("Email subject"), + category: z + .string() + .describe("Category of the email (work, personal, marketing, etc.)"), +}); +export type EmailSummary = z.infer; + +export async function aiSummarizeEmails( + emails: EmailForLLM[], + emailAccount: EmailAccountWithAI, +): Promise { + if (emails.length === 0) { + logger.warn("No emails to summarize, returning empty array"); + return []; + } + + const batchSize = 15; + const results: EmailSummary[] = []; + + for (let i = 0; i < emails.length; i += batchSize) { + const batch = emails.slice(i, i + batchSize); + const batchNumber = Math.floor(i / batchSize) + 1; + const totalBatches = Math.ceil(emails.length / batchSize); + + const batchResults = await processEmailBatch( + batch, + emailAccount, + batchNumber, + totalBatches, + ); + results.push(...batchResults); + + if (i + batchSize < emails.length) { + await sleep(1000); + } + } + + return results; +} + +async function processEmailBatch( + emails: EmailForLLM[], + emailAccount: EmailAccountWithAI, + batchNumber: number, + totalBatches: number, +): Promise { + const system = `You are an assistant that processes user emails to extract their core meaning for later analysis. + +For each email, write a **factual summary of 3–5 sentences** that clearly describes: +- The main topic or purpose of the email +- What the sender wants, requests, or informs +- Any relevant secondary detail (e.g., urgency, timing, sender role, or context) +- Optional: mention tools, platforms, or projects if they help clarify the email's purpose + +**Important Rules:** +- Be objective. Do **not** speculate, interpret intent, or invent details. +- Summarize only what is in the actual content of the email. +- Use professional and concise language. +- **Include** marketing/newsletter emails **only if** they reflect the user's professional interests (e.g., product updates, industry news, job boards). +- **Skip** irrelevant promotions, spam, or generic sales offers (e.g., holiday deals, coupon codes).`; + + const prompt = ` +**Input Emails (Batch ${batchNumber} of ${totalBatches}):** + +${emails.map((email) => `${stringifyEmail(email, 2000)}`).join("\n")} + +Return the analysis as a JSON array of objects.`; + + logger.trace("Input", { system, prompt }); + + const modelOptions = getModel(emailAccount.user, "economy"); + + const generateObject = createGenerateObject({ + userEmail: emailAccount.email, + label: "email-report-summary-generation", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system, + prompt, + schema: z.object({ + summaries: z + .array(emailSummarySchema) + .describe("Summaries of the emails"), + }), + }); + + logger.trace("Output", { result: result.object.summaries }); + + return result.object.summaries; +} diff --git a/apps/web/utils/get-email-from-message.ts b/apps/web/utils/get-email-from-message.ts index 498cad4cbb..7e9c9944fe 100644 --- a/apps/web/utils/get-email-from-message.ts +++ b/apps/web/utils/get-email-from-message.ts @@ -2,6 +2,7 @@ import type { ParsedMessage, EmailForLLM } from "@/utils/types"; import { emailToContent, type EmailToContentOptions } from "@/utils/mail"; import { internalDateToDate } from "@/utils/date"; +// Convert a ParsedMessage to an EmailForLLM export function getEmailForLLM( message: ParsedMessage, contentOptions?: EmailToContentOptions, diff --git a/apps/web/utils/types.ts b/apps/web/utils/types.ts index 4afc12efc1..ee3663ef69 100644 --- a/apps/web/utils/types.ts +++ b/apps/web/utils/types.ts @@ -113,6 +113,7 @@ export interface ParsedMessageHeaders { "list-unsubscribe"?: string; } +// Note: use `getEmailForLLM(message)` to convert a `ParsedMessage` to an `EmailForLLM` export type EmailForLLM = { id: string; from: string;