From 4b7016b53a6f73d5989fb87d132f473b78ba0dd8 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 07:46:33 +0300 Subject: [PATCH 01/25] Use emailprovider in more places --- apps/web/app/api/threads/validation.ts | 2 ++ apps/web/utils/actions/clean.ts | 20 ++++++------- apps/web/utils/actions/whitelist.ts | 11 +++---- apps/web/utils/email/google.ts | 39 +++++++++++++++++-------- apps/web/utils/email/microsoft.ts | 40 +++++++++++++++++--------- 5 files changed, 71 insertions(+), 41 deletions(-) diff --git a/apps/web/app/api/threads/validation.ts b/apps/web/app/api/threads/validation.ts index e4f0478345..2573d524a0 100644 --- a/apps/web/app/api/threads/validation.ts +++ b/apps/web/app/api/threads/validation.ts @@ -6,6 +6,8 @@ export const threadsQuery = z.object({ type: z.string().nullish(), nextPageToken: z.string().nullish(), labelId: z.string().nullish(), // For Google + labelIds: z.array(z.string()).nullish(), // For Google + excludeLabelNames: z.array(z.string()).nullish(), // For Google after: z.coerce.date().nullish(), before: z.coerce.date().nullish(), isUnread: z.coerce.boolean().nullish(), diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index be80653dd7..b4abc86172 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -29,6 +29,7 @@ import { createEmailProvider } from "@/utils/email/provider"; import { isGoogleProvider } from "@/utils/email/provider-types"; import { getUserPremium } from "@/utils/user/get"; import { isActivePremium } from "@/utils/premium"; +import { ONE_DAY_MS } from "@/utils/date"; export const cleanInboxAction = actionClient .metadata({ name: "cleanInbox" }) @@ -48,7 +49,6 @@ export const cleanInboxAction = actionClient if (!premium) throw new SafeError("User not premium"); if (!isActivePremium(premium)) throw new SafeError("Premium not active"); - const gmail = await getGmailClientForEmail({ emailAccountId }); const emailProvider = await createEmailProvider({ emailAccountId, provider, @@ -113,18 +113,18 @@ export const cleanInboxAction = actionClient let totalEmailsProcessed = 0; - const query = `${daysOld ? `older_than:${daysOld}d ` : ""}-in:"${inboxZeroLabels.processed.name}"`; - do { // fetch all emails from the user's inbox const { threads, nextPageToken: pageToken } = - await getThreadsWithNextPageToken({ - gmail, - q: query, - labelIds: - type === "inbox" - ? [GmailLabel.INBOX] - : [GmailLabel.INBOX, GmailLabel.UNREAD], + await emailProvider.getThreadsWithQuery({ + query: { + before: new Date(Date.now() - daysOld * ONE_DAY_MS), + labelIds: + type === "inbox" + ? [GmailLabel.INBOX] + : [GmailLabel.INBOX, GmailLabel.UNREAD], + excludeLabelNames: [inboxZeroLabels.processed.name], + }, maxResults: Math.min(maxEmails || 100, 100), }); diff --git a/apps/web/utils/actions/whitelist.ts b/apps/web/utils/actions/whitelist.ts index 84411494a2..1b7486472d 100644 --- a/apps/web/utils/actions/whitelist.ts +++ b/apps/web/utils/actions/whitelist.ts @@ -1,11 +1,10 @@ "use server"; import { env } from "@/env"; -import { createFilter } from "@/utils/gmail/filter"; import { GmailLabel } from "@/utils/gmail/label"; import { actionClient } from "@/utils/actions/safe-action"; -import { getGmailClientForEmail } from "@/utils/account"; import { isGoogleProvider } from "@/utils/email/provider-types"; +import { createEmailProvider } from "@/utils/email/provider"; export const whitelistInboxZeroAction = actionClient .metadata({ name: "whitelistInboxZero" }) @@ -13,10 +12,12 @@ export const whitelistInboxZeroAction = actionClient if (!env.WHITELIST_FROM) return; if (!isGoogleProvider(provider)) return; - const gmail = await getGmailClientForEmail({ emailAccountId }); + const emailProvider = await createEmailProvider({ + emailAccountId, + provider, + }); - await createFilter({ - gmail, + await emailProvider.createFilter({ from: env.WHITELIST_FROM, addLabelIds: ["CATEGORY_PERSONAL", GmailLabel.IMPORTANT], removeLabelIds: [GmailLabel.SPAM], diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 8b7acef959..27d63cc6a1 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -610,37 +610,54 @@ export class GmailProvider implements EmailProvider { threads: EmailThread[]; nextPageToken?: string; }> { - const query = options.query; + const { + fromEmail, + after, + before, + isUnread, + type, + excludeLabelNames, + labelIds, + labelId, + } = options.query || {}; function getQuery() { const queryParts: string[] = []; - if (query?.fromEmail) { - queryParts.push(`from:${query.fromEmail}`); + if (fromEmail) { + queryParts.push(`from:${fromEmail}`); } - if (query?.after) { - const afterSeconds = Math.floor(query.after.getTime() / 1000); + if (after) { + const afterSeconds = Math.floor(after.getTime() / 1000); queryParts.push(`after:${afterSeconds}`); } - if (query?.before) { - const beforeSeconds = Math.floor(query.before.getTime() / 1000); + if (before) { + const beforeSeconds = Math.floor(before.getTime() / 1000); queryParts.push(`before:${beforeSeconds}`); } - if (query?.isUnread) { + if (isUnread) { queryParts.push("is:unread"); } - if (query?.type === "archive") { + if (type === "archive") { queryParts.push(`-label:${GmailLabel.INBOX}`); } + if (excludeLabelNames) { + queryParts.push(`-in:"${excludeLabelNames.join(" ")}"`); + } + return queryParts.length > 0 ? queryParts.join(" ") : undefined; } function getLabelIds(type?: string | null) { + if (labelIds) { + return labelIds; + } + switch (type) { case "inbox": return [GmailLabel.INBOX]; @@ -673,9 +690,7 @@ export class GmailProvider implements EmailProvider { await getThreadsWithNextPageToken({ gmail: this.client, q: getQuery(), - labelIds: query?.labelId - ? [query.labelId] - : getLabelIds(query?.type) || [], + labelIds: labelId ? [labelId] : getLabelIds(type) || [], maxResults: options.maxResults || 50, pageToken: options.pageToken || undefined, }); diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 044e4c585c..d088801af1 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -655,7 +655,19 @@ export class OutlookProvider implements EmailProvider { threads: EmailThread[]; nextPageToken?: string; }> { - const query = options.query; + const { + fromEmail, + after, + before, + isUnread, + type, + labelId, + // biome-ignore lint/correctness/noUnusedVariables: to do + labelIds, + // biome-ignore lint/correctness/noUnusedVariables: to do + excludeLabelNames, + } = options.query || {}; + const client = this.client.getClient(); // Determine endpoint and build filters based on query type @@ -663,40 +675,40 @@ export class OutlookProvider implements EmailProvider { const filters: string[] = []; // Route to appropriate endpoint based on type - if (query?.type === "sent") { + if (type === "sent") { endpoint = "/me/mailFolders('sentitems')/messages"; - } else if (query?.type === "all") { + } else if (type === "all") { // For "all" type, use default messages endpoint with folder filter filters.push( "(parentFolderId eq 'inbox' or parentFolderId eq 'archive')", ); - } else if (query?.labelId) { + } else if (labelId) { // Use labelId as parentFolderId (should be lowercase for Outlook) - filters.push(`parentFolderId eq '${query.labelId.toLowerCase()}'`); + filters.push(`parentFolderId eq '${labelId.toLowerCase()}'`); } else { // Default to inbox only filters.push("parentFolderId eq 'inbox'"); } // Add other filters - if (query?.fromEmail) { + if (fromEmail) { // Escape single quotes in email address - const escapedEmail = escapeODataString(query.fromEmail); + const escapedEmail = escapeODataString(fromEmail); filters.push(`from/emailAddress/address eq '${escapedEmail}'`); } // Handle structured date options - if (query?.after) { - const afterISO = query.after.toISOString(); + if (after) { + const afterISO = after.toISOString(); filters.push(`receivedDateTime gt ${afterISO}`); } - if (query?.before) { - const beforeISO = query.before.toISOString(); + if (before) { + const beforeISO = before.toISOString(); filters.push(`receivedDateTime lt ${beforeISO}`); } - if (query?.isUnread) { + if (isUnread) { filters.push("isRead eq false"); } @@ -716,7 +728,7 @@ export class OutlookProvider implements EmailProvider { } // Only add ordering if we don't have a fromEmail filter to avoid complexity - if (!query?.fromEmail) { + if (!fromEmail) { request = request.orderby("receivedDateTime DESC"); } @@ -729,7 +741,7 @@ export class OutlookProvider implements EmailProvider { // Sort messages by receivedDateTime if we filtered by fromEmail (since we couldn't use orderby) let sortedMessages = response.value; - if (query?.fromEmail) { + if (fromEmail) { sortedMessages = response.value.sort( (a: { receivedDateTime: string }, b: { receivedDateTime: string }) => new Date(b.receivedDateTime).getTime() - From 640896f4f192f6de5b27aa7e520bcf3e83ddf373 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 07:52:10 +0300 Subject: [PATCH 02/25] remove gmail specific code --- .../group/[groupId]/messages/controller.ts | 40 ++++++++++--------- .../user/group/[groupId]/messages/route.ts | 9 ++--- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/apps/web/app/api/user/group/[groupId]/messages/controller.ts b/apps/web/app/api/user/group/[groupId]/messages/controller.ts index 76d5c63381..1248999edf 100644 --- a/apps/web/app/api/user/group/[groupId]/messages/controller.ts +++ b/apps/web/app/api/user/group/[groupId]/messages/controller.ts @@ -1,14 +1,13 @@ import prisma from "@/utils/prisma"; -import type { gmail_v1 } from "@googleapis/gmail"; import { createHash } from "node:crypto"; import groupBy from "lodash/groupBy"; -import { getMessage, getMessages } from "@/utils/gmail/message"; import { findMatchingGroupItem } from "@/utils/group/find-matching-group"; -import { parseMessage } from "@/utils/gmail/message"; import { extractEmailAddress } from "@/utils/email"; import { type GroupItem, GroupItemType } from "@prisma/client"; import type { MessageWithGroupItem } from "@/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types"; import { SafeError } from "@/utils/error"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { EmailProvider } from "@/utils/email/types"; const PAGE_SIZE = 20; @@ -22,16 +21,16 @@ interface InternalPaginationState { export type GroupEmailsResponse = Awaited>; export async function getGroupEmails({ + provider, groupId, emailAccountId, - gmail, from, to, pageToken, }: { + provider: string; groupId: string; emailAccountId: string; - gmail: gmail_v1.Gmail; from?: Date; to?: Date; pageToken?: string; @@ -43,9 +42,14 @@ export async function getGroupEmails({ if (!group) throw new SafeError("Group not found"); + const emailProvider = await createEmailProvider({ + emailAccountId, + provider, + }); + const { messages, nextPageToken } = await fetchPaginatedMessages({ + emailProvider, groupItems: group.items, - gmail, from, to, pageToken, @@ -55,14 +59,14 @@ export async function getGroupEmails({ } export async function fetchPaginatedMessages({ + emailProvider, groupItems, - gmail, from, to, pageToken, }: { + emailProvider: EmailProvider; groupItems: GroupItem[]; - gmail: gmail_v1.Gmail; from?: Date; to?: Date; pageToken?: string; @@ -97,7 +101,7 @@ export async function fetchPaginatedMessages({ const { messages, nextPaginationState } = await fetchPaginatedGroupMessages( groupItems, - gmail, + emailProvider, from, to, paginationState, @@ -126,7 +130,7 @@ function createGroupItemsHash( // and for each type, through multiple chunks async function fetchPaginatedGroupMessages( groupItems: GroupItem[], - gmail: gmail_v1.Gmail, + emailProvider: EmailProvider, from: Date | undefined, to: Date | undefined, paginationState: InternalPaginationState, @@ -157,7 +161,7 @@ async function fetchPaginatedGroupMessages( const result = await fetchGroupMessages( type, chunk, - gmail, + emailProvider, PAGE_SIZE - messages.length, from, to, @@ -206,7 +210,7 @@ async function fetchPaginatedGroupMessages( async function fetchGroupMessages( groupItemType: GroupItemType, groupItems: GroupItem[], - gmail: gmail_v1.Gmail, + emailProvider: EmailProvider, maxResults: number, from?: Date, to?: Date, @@ -214,22 +218,20 @@ async function fetchGroupMessages( ): Promise<{ messages: MessageWithGroupItem[]; nextPageToken?: string }> { const query = buildQuery(groupItemType, groupItems, from, to); - const response = await getMessages(gmail, { + const response = await emailProvider.getMessagesWithPagination({ query, maxResults, pageToken, }); const messages = await Promise.all( - (response.messages || []).map(async (message) => { - // TODO: Use email provider to get the message which will parse it internally - const m = await getMessage(message.id!, gmail); - const parsedMessage = parseMessage(m); + (response.messages || []).map(async (m) => { + const message = await emailProvider.getMessage(m.id); const matchingGroupItem = findMatchingGroupItem( - parsedMessage.headers, + message.headers, groupItems, ); - return { ...parsedMessage, matchingGroupItem }; + return { ...message, matchingGroupItem }; }), ); diff --git a/apps/web/app/api/user/group/[groupId]/messages/route.ts b/apps/web/app/api/user/group/[groupId]/messages/route.ts index b04e477507..dda0a98660 100644 --- a/apps/web/app/api/user/group/[groupId]/messages/route.ts +++ b/apps/web/app/api/user/group/[groupId]/messages/route.ts @@ -1,20 +1,17 @@ import { NextResponse } from "next/server"; -import { withEmailAccount } from "@/utils/middleware"; +import { withEmailProvider } from "@/utils/middleware"; import { getGroupEmails } from "@/app/api/user/group/[groupId]/messages/controller"; -import { getGmailClientForEmail } from "@/utils/account"; -export const GET = withEmailAccount(async (request, { params }) => { +export const GET = withEmailProvider(async (request, { params }) => { const emailAccountId = request.auth.emailAccountId; const { groupId } = await params; if (!groupId) return NextResponse.json({ error: "Missing group id" }); - const gmail = await getGmailClientForEmail({ emailAccountId }); - const { messages } = await getGroupEmails({ + provider: request.emailProvider.name, groupId, emailAccountId, - gmail, from: undefined, to: undefined, pageToken: "", From 2938a115f4142f0ca28e31e9c9e506bceaeae8fa Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:19:05 +0300 Subject: [PATCH 03/25] move more to generic email provider --- apps/web/app/api/google/watch/controller.ts | 30 +++----- apps/web/app/api/google/watch/route.ts | 12 ++- apps/web/app/api/user/stats/route.ts | 51 ++++++------- apps/web/utils/account.ts | 22 ------ apps/web/utils/actions/mail.ts | 54 ++----------- apps/web/utils/email/google.ts | 73 ++++++++++++++++++ apps/web/utils/email/microsoft.ts | 85 +++++++++++++++++++++ apps/web/utils/email/types.ts | 31 ++++++++ apps/web/utils/gmail/label.ts | 20 ----- apps/web/utils/outlook/message.ts | 11 ++- 10 files changed, 244 insertions(+), 145 deletions(-) diff --git a/apps/web/app/api/google/watch/controller.ts b/apps/web/app/api/google/watch/controller.ts index 8ca9ec0591..2eaf8c7bb7 100644 --- a/apps/web/app/api/google/watch/controller.ts +++ b/apps/web/app/api/google/watch/controller.ts @@ -1,24 +1,22 @@ -import type { gmail_v1 } from "@googleapis/gmail"; import prisma from "@/utils/prisma"; -import { getGmailClientWithRefresh } from "@/utils/gmail/client"; import { captureException } from "@/utils/error"; import { createScopedLogger } from "@/utils/logger"; -import { watchGmail, unwatchGmail } from "@/utils/gmail/watch"; +import type { EmailProvider } from "@/utils/email/types"; const logger = createScopedLogger("google/watch"); export async function watchEmails({ emailAccountId, - gmail, + emailProvider, }: { emailAccountId: string; - gmail: gmail_v1.Gmail; + emailProvider: EmailProvider; }) { logger.info("Watching emails", { emailAccountId }); - const res = await watchGmail(gmail); + const res = await emailProvider.watchEmails(); - if (res.expiration) { - const expirationDate = new Date(+res.expiration); + if (res?.expirationDate) { + const expirationDate = new Date(res.expirationDate); await prisma.emailAccount.update({ where: { id: emailAccountId }, data: { watchEmailsExpirationDate: expirationDate }, @@ -30,24 +28,14 @@ export async function watchEmails({ export async function unwatchEmails({ emailAccountId, - accessToken, - refreshToken, - expiresAt, + emailProvider, }: { emailAccountId: string; - accessToken: string | null; - refreshToken: string | null; - expiresAt: number | null; + emailProvider: EmailProvider; }) { try { logger.info("Unwatching emails", { emailAccountId }); - const gmail = await getGmailClientWithRefresh({ - accessToken, - refreshToken, - expiresAt, - emailAccountId, - }); - await unwatchGmail(gmail); + await emailProvider.unwatchEmails(); } catch (error) { if (error instanceof Error && error.message.includes("invalid_grant")) { logger.warn("Error unwatching emails, invalid grant", { emailAccountId }); diff --git a/apps/web/app/api/google/watch/route.ts b/apps/web/app/api/google/watch/route.ts index 48bbf003c1..898df1686f 100644 --- a/apps/web/app/api/google/watch/route.ts +++ b/apps/web/app/api/google/watch/route.ts @@ -2,8 +2,8 @@ import { NextResponse } from "next/server"; import { watchEmails } from "./controller"; import { withAuth } from "@/utils/middleware"; import { createScopedLogger } from "@/utils/logger"; -import { getGmailClientForEmailId } from "@/utils/account"; import prisma from "@/utils/prisma"; +import { createEmailProvider } from "@/utils/email/provider"; export const dynamic = "force-dynamic"; @@ -27,8 +27,14 @@ export const GET = withAuth(async (request) => { for (const { id: emailAccountId } of emailAccounts) { try { - const gmail = await getGmailClientForEmailId({ emailAccountId }); - const expirationDate = await watchEmails({ emailAccountId, gmail }); + const emailProvider = await createEmailProvider({ + emailAccountId, + provider: "google", + }); + const expirationDate = await watchEmails({ + emailAccountId, + emailProvider, + }); if (expirationDate) { results.push({ diff --git a/apps/web/app/api/user/stats/route.ts b/apps/web/app/api/user/stats/route.ts index eb665de679..c03ee97530 100644 --- a/apps/web/app/api/user/stats/route.ts +++ b/apps/web/app/api/user/stats/route.ts @@ -1,15 +1,10 @@ -import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; -import { dateToSeconds } from "@/utils/date"; -import { getMessages } from "@/utils/gmail/message"; -import { getGmailClientForEmailId } from "@/utils/account"; -import { withEmailAccount } from "@/utils/middleware"; +import { withEmailProvider } from "@/utils/middleware"; +import type { EmailProvider } from "@/utils/email/types"; export type StatsResponse = Awaited>; -async function getStats(options: { gmail: gmail_v1.Gmail }) { - const { gmail } = options; - +async function getStats({ emailProvider }: { emailProvider: EmailProvider }) { const now = new Date(); const twentyFourHoursAgo = new Date( now.getFullYear(), @@ -22,9 +17,6 @@ async function getStats(options: { gmail: gmail_v1.Gmail }) { now.getDate() - 7, ); - const twentyFourHoursAgoInSeconds = dateToSeconds(twentyFourHoursAgo); - const sevenDaysAgoInSeconds = dateToSeconds(sevenDaysAgo); - const [ emailsReceived24hrs, emailsSent24hrs, @@ -33,30 +25,36 @@ async function getStats(options: { gmail: gmail_v1.Gmail }) { emailsSent7days, emailsInbox7days, ] = await Promise.all([ - getMessages(gmail, { - query: `-in:sent after:${twentyFourHoursAgoInSeconds}`, + emailProvider.getMessagesByFields({ + after: twentyFourHoursAgo, + excludeSent: true, maxResults: 500, }), - getMessages(gmail, { - query: `in:sent after:${twentyFourHoursAgoInSeconds}`, + emailProvider.getMessagesByFields({ + after: twentyFourHoursAgo, + type: "sent", maxResults: 500, }), - getMessages(gmail, { - query: `in:inbox after:${twentyFourHoursAgoInSeconds}`, + emailProvider.getMessagesByFields({ + after: twentyFourHoursAgo, + type: "inbox", maxResults: 500, }), // 7 days - getMessages(gmail, { - query: `-in:sent after:${sevenDaysAgoInSeconds}`, + emailProvider.getMessagesByFields({ + after: sevenDaysAgo, + excludeSent: true, maxResults: 500, }), - getMessages(gmail, { - query: `in:sent after:${sevenDaysAgoInSeconds}`, + emailProvider.getMessagesByFields({ + after: sevenDaysAgo, + type: "sent", maxResults: 500, }), - getMessages(gmail, { - query: `in:inbox after:${sevenDaysAgoInSeconds}`, + emailProvider.getMessagesByFields({ + after: sevenDaysAgo, + type: "inbox", maxResults: 500, }), ]); @@ -72,11 +70,8 @@ async function getStats(options: { gmail: gmail_v1.Gmail }) { }; } -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; - - const gmail = await getGmailClientForEmailId({ emailAccountId }); - const result = await getStats({ gmail }); +export const GET = withEmailProvider(async (request) => { + const result = await getStats({ emailProvider: request.emailProvider }); return NextResponse.json(result); }); diff --git a/apps/web/utils/account.ts b/apps/web/utils/account.ts index 47fbc67877..cb81a56b7b 100644 --- a/apps/web/utils/account.ts +++ b/apps/web/utils/account.ts @@ -42,28 +42,6 @@ export async function getGmailAndAccessTokenForEmail({ return { gmail, accessToken, tokens }; } -export async function getGmailClientForEmailId({ - emailAccountId, -}: { - emailAccountId: string; -}) { - const account = await prisma.emailAccount.findUnique({ - where: { id: emailAccountId }, - select: { - account: { - select: { access_token: true, refresh_token: true, expires_at: true }, - }, - }, - }); - const gmail = getGmailClientWithRefresh({ - accessToken: account?.account.access_token, - refreshToken: account?.account.refresh_token || "", - expiresAt: account?.account.expires_at?.getTime() ?? null, - emailAccountId, - }); - return gmail; -} - export async function getOutlookClientForEmail({ emailAccountId, }: { diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index 70930138be..7547a6eb93 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -3,11 +3,8 @@ import { z } from "zod"; import prisma from "@/utils/prisma"; import { saveUserLabels } from "@/utils/redis/label"; -import { markImportantMessage } from "@/utils/gmail/label"; -import { markSpam } from "@/utils/gmail/spam"; -import { sendEmailWithHtml, sendEmailBody } from "@/utils/gmail/mail"; +import { sendEmailBody } from "@/utils/gmail/mail"; import { actionClient } from "@/utils/actions/safe-action"; -import { getGmailClientForEmail } from "@/utils/account"; import { SafeError } from "@/utils/error"; import { createEmailProvider } from "@/utils/email/provider"; @@ -52,17 +49,6 @@ export const trashThreadAction = actionClient }, ); -// export const trashMessageAction = actionClient -// .metadata({ name: "trashMessage" }) -// .schema(z.object({ messageId: z.string() })) -// .action(async ({ ctx: { emailAccountId }, parsedInput: { messageId } }) => { -// const gmail = await getGmailClientForEmail({ emailAccountId }); - -// const res = await trashMessage({ gmail, messageId }); - -// if (!isStatusOk(res.status)) throw new SafeError("Failed to delete message"); -// }); - export const markReadThreadAction = actionClient .metadata({ name: "markReadThread" }) .schema(z.object({ threadId: z.string(), read: z.boolean() })) @@ -80,35 +66,6 @@ export const markReadThreadAction = actionClient }, ); -export const markImportantMessageAction = actionClient - .metadata({ name: "markImportantMessage" }) - .schema(z.object({ messageId: z.string(), important: z.boolean() })) - .action( - async ({ - ctx: { emailAccountId }, - parsedInput: { messageId, important }, - }) => { - const gmail = await getGmailClientForEmail({ emailAccountId }); - - const res = await markImportantMessage({ gmail, messageId, important }); - - if (!isStatusOk(res.status)) - throw new SafeError("Failed to mark message as important"); - }, - ); - -export const markSpamThreadAction = actionClient - .metadata({ name: "markSpamThread" }) - .schema(z.object({ threadId: z.string() })) - .action(async ({ ctx: { emailAccountId }, parsedInput: { threadId } }) => { - const gmail = await getGmailClientForEmail({ emailAccountId }); - - const res = await markSpam({ gmail, threadId }); - - if (!isStatusOk(res.status)) - throw new SafeError("Failed to mark thread as spam"); - }); - export const createAutoArchiveFilterAction = actionClient .metadata({ name: "createAutoArchiveFilter" }) .schema( @@ -253,10 +210,13 @@ export const updateLabelsAction = actionClient export const sendEmailAction = actionClient .metadata({ name: "sendEmail" }) .schema(sendEmailBody) - .action(async ({ ctx: { emailAccountId }, parsedInput }) => { - const gmail = await getGmailClientForEmail({ emailAccountId }); + .action(async ({ ctx: { emailAccountId, provider }, parsedInput }) => { + const emailProvider = await createEmailProvider({ + emailAccountId, + provider, + }); - const result = await sendEmailWithHtml(gmail, parsedInput); + const result = await emailProvider.sendEmailWithHtml(parsedInput); return { success: true, diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 27d63cc6a1..ed382979b6 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -25,6 +25,7 @@ import { forwardEmail, replyToEmail, sendEmailWithPlainText, + sendEmailWithHtml, } from "@/utils/gmail/mail"; import { archiveThread, @@ -64,6 +65,7 @@ import type { EmailFilter, } from "@/utils/email/types"; import { createScopedLogger } from "@/utils/logger"; +import { extractEmailAddress } from "@/utils/email"; const logger = createScopedLogger("gmail-provider"); @@ -312,6 +314,27 @@ export class GmailProvider implements EmailProvider { await sendEmailWithPlainText(this.client, args); } + async sendEmailWithHtml(body: { + replyToEmail?: { + threadId: string; + headerMessageId: string; + references?: string; + }; + to: string; + cc?: string; + bcc?: string; + replyTo?: string; + subject: string; + messageHtml: string; + attachments?: Array<{ + filename: string; + content: string; + contentType: string; + }>; + }): Promise { + return await sendEmailWithHtml(this.client, body); + } + async forwardEmail( email: ParsedMessage, args: { to: string; cc?: string; bcc?: string; content?: string }, @@ -517,6 +540,56 @@ export class GmailProvider implements EmailProvider { }); } + async getMessagesByFields(options: { + froms?: string[]; + subjects?: string[]; + before?: Date; + after?: Date; + type?: "inbox" | "sent" | "all"; + excludeSent?: boolean; + maxResults?: number; + pageToken?: string; + }): Promise<{ + messages: ParsedMessage[]; + nextPageToken?: string; + }> { + const parts: string[] = []; + + const froms = (options.froms || []) + .map((f) => extractEmailAddress(f) || f) + .filter((f) => !!f); + if (froms.length > 0) { + const fromGroup = froms.map((f) => `"${f}"`).join(" OR "); + parts.push(`from:(${fromGroup})`); + } + + const subjects = (options.subjects || []).filter((s) => !!s); + if (subjects.length > 0) { + const subjectGroup = subjects.map((s) => `"${s}"`).join(" OR "); + parts.push(`subject:(${subjectGroup})`); + } + + // Scope by type/exclusion + if (options.type === "inbox") { + parts.push(`in:${GmailLabel.INBOX}`); + } else if (options.type === "sent") { + parts.push(`in:${GmailLabel.SENT}`); + } + if (options.excludeSent) { + parts.push(`-in:${GmailLabel.SENT}`); + } + + const query = parts.join(" ") || undefined; + + return this.getMessagesWithPagination({ + query, + maxResults: options.maxResults, + pageToken: options.pageToken, + before: options.before, + after: options.after, + }); + } + async getMessagesBatch(messageIds: string[]): Promise { return getMessagesBatch({ messageIds, diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index d088801af1..27308ceb24 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -20,6 +20,7 @@ import { forwardEmail, replyToEmail, sendEmailWithPlainText, + sendEmailWithHtml, } from "@/utils/outlook/mail"; import { archiveThread, @@ -58,6 +59,7 @@ import type { } from "@/utils/email/types"; import { unwatchOutlook, watchOutlook } from "@/utils/outlook/watch"; import { escapeODataString } from "@/utils/outlook/odata-escape"; +import { extractEmailAddress } from "@/utils/email"; const logger = createScopedLogger("outlook-provider"); @@ -318,6 +320,27 @@ export class OutlookProvider implements EmailProvider { await sendEmailWithPlainText(this.client, args); } + async sendEmailWithHtml(body: { + replyToEmail?: { + threadId: string; + headerMessageId: string; + references?: string; + }; + to: string; + cc?: string; + bcc?: string; + replyTo?: string; + subject: string; + messageHtml: string; + attachments?: Array<{ + filename: string; + content: string; + contentType: string; + }>; + }): Promise { + return await sendEmailWithHtml(this.client, body); + } + async forwardEmail( email: ParsedMessage, args: { to: string; cc?: string; bcc?: string; content?: string }, @@ -555,6 +578,68 @@ export class OutlookProvider implements EmailProvider { }); } + async getMessagesByFields(options: { + froms?: string[]; + subjects?: string[]; + before?: Date; + after?: Date; + type?: "inbox" | "sent" | "all"; + excludeSent?: boolean; + maxResults?: number; + pageToken?: string; + }): Promise<{ + messages: ParsedMessage[]; + nextPageToken?: string; + }> { + const filters: string[] = []; + + // Scope by folder(s) + if (options.type === "sent") { + // Limit to sent folder + filters.push("parentFolderId eq 'sentitems'"); + } else if (options.type === "inbox") { + filters.push("parentFolderId eq 'inbox'"); + } else { + // Default/all -> include inbox and archive + filters.push( + "(parentFolderId eq 'inbox' or parentFolderId eq 'archive')", + ); + } + + if (options.excludeSent) { + filters.push("parentFolderId ne 'sentitems'"); + } + + const froms = (options.froms || []) + .map((f) => extractEmailAddress(f) || f) + .filter((f) => !!f); + if (froms.length > 0) { + const fromFilter = froms + .map((f) => `from/emailAddress/address eq '${escapeODataString(f)}'`) + .join(" or "); + filters.push(`(${fromFilter})`); + } + + const subjects = (options.subjects || []).filter((s) => !!s); + if (subjects.length > 0) { + // Use contains to match subject substrings; exact eq would be too strict + const subjectFilter = subjects + .map((s) => `contains(subject,'${escapeODataString(s)}')`) + .join(" or "); + filters.push(`(${subjectFilter})`); + } + + const query = filters.join(" and ") || undefined; + + return this.getMessagesWithPagination({ + query, + maxResults: options.maxResults, + pageToken: options.pageToken, + before: options.before, + after: options.after, + }); + } + async getMessagesBatch(messageIds: string[]): Promise { // For Outlook, we need to fetch messages individually since there's no batch endpoint const messagePromises = messageIds.map((messageId) => diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index bc837281b5..dc52ba6128 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -41,6 +41,19 @@ export interface EmailProvider { getLabelById(labelId: string): Promise; getMessage(messageId: string): Promise; getMessages(query?: string, maxResults?: number): Promise; + getMessagesByFields(options: { + froms?: string[]; + subjects?: string[]; + before?: Date; + after?: Date; + type?: "inbox" | "sent" | "all"; + excludeSent?: boolean; + maxResults?: number; + pageToken?: string; + }): Promise<{ + messages: ParsedMessage[]; + nextPageToken?: string; + }>; getSentMessages(maxResults?: number): Promise; getSentThreadsExcluding(options: { excludeToEmails?: string[]; @@ -85,6 +98,24 @@ export interface EmailProvider { subject: string; messageText: string; }): Promise; + sendEmailWithHtml(body: { + replyToEmail?: { + threadId: string; + headerMessageId: string; + references?: string; + }; + to: string; + cc?: string; + bcc?: string; + replyTo?: string; + subject: string; + messageHtml: string; + attachments?: Array<{ + filename: string; + content: string; + contentType: string; + }>; + }): Promise; forwardEmail( email: ParsedMessage, args: { to: string; cc?: string; bcc?: string; content?: string }, diff --git a/apps/web/utils/gmail/label.ts b/apps/web/utils/gmail/label.ts index 787ec9812c..72487e666a 100644 --- a/apps/web/utils/gmail/label.ts +++ b/apps/web/utils/gmail/label.ts @@ -168,26 +168,6 @@ export async function markReadThread(options: { }); } -export async function markImportantMessage(options: { - gmail: gmail_v1.Gmail; - messageId: string; - important: boolean; -}) { - const { gmail, messageId, important } = options; - - return gmail.users.messages.modify({ - userId: "me", - id: messageId, - requestBody: important - ? { - addLabelIds: [GmailLabel.IMPORTANT], - } - : { - removeLabelIds: [GmailLabel.IMPORTANT], - }, - }); -} - export async function createLabel({ gmail, name, diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index 201cec8245..9466583d6b 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -174,11 +174,14 @@ export async function queryBatchMessages( if (query?.trim()) { if (isODataFilter) { // Filter path - use filter and skipToken - // Combine the existing filter with folder restrictions + // Combine the existing filter with folder restrictions unless the query already constrains parentFolderId + const hasFolderConstraint = query.includes("parentFolderId"); const folderFilter = `(parentFolderId eq '${inboxFolderId}' or parentFolderId eq '${archiveFolderId}')`; - const combinedFilter = query.trim() - ? `${query.trim()} and ${folderFilter}` - : folderFilter; + const combinedFilter = hasFolderConstraint + ? query.trim() + : query.trim() + ? `${query.trim()} and ${folderFilter}` + : folderFilter; request = request.filter(combinedFilter); if (pageToken) { From 6e90e304839c6da03641ebfe8c48c4c7c9b3c388 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:22:25 +0300 Subject: [PATCH 04/25] fix styling --- apps/web/app/(app)/accounts/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(app)/accounts/page.tsx b/apps/web/app/(app)/accounts/page.tsx index 4cfc848b22..8f6a82dbaa 100644 --- a/apps/web/app/(app)/accounts/page.tsx +++ b/apps/web/app/(app)/accounts/page.tsx @@ -90,7 +90,7 @@ function AccountItem({ - {!emailAccount.isPrimary && ( From 2da28575be6c200fc1bd7ec64c5653bbae1e08d8 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:24:45 +0300 Subject: [PATCH 05/25] add links --- apps/web/app/(app)/accounts/page.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/accounts/page.tsx b/apps/web/app/(app)/accounts/page.tsx index 8f6a82dbaa..9ebe586724 100644 --- a/apps/web/app/(app)/accounts/page.tsx +++ b/apps/web/app/(app)/accounts/page.tsx @@ -2,7 +2,7 @@ import { useAction } from "next-safe-action/hooks"; import Link from "next/link"; -import { Trash2, ArrowRight } from "lucide-react"; +import { Trash2, ArrowRight, BotIcon } from "lucide-react"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { LoadingContent } from "@/components/LoadingContent"; import { Button } from "@/components/ui/button"; @@ -90,8 +90,13 @@ function AccountItem({ + {!emailAccount.isPrimary && ( Date: Fri, 26 Sep 2025 08:26:15 +0300 Subject: [PATCH 06/25] enable analytics for microsoft --- apps/web/components/SideNav.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index f830a74f1a..499a9bcc14 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -94,13 +94,13 @@ export const useNavigation = () => { href: prefixPath(currentEmailAccountId, "/clean"), icon: BrushIcon, }, - { - name: "Analytics", - href: prefixPath(currentEmailAccountId, "/stats"), - icon: BarChartBigIcon, - }, ] : []), + { + name: "Analytics", + href: prefixPath(currentEmailAccountId, "/stats"), + icon: BarChartBigIcon, + }, ], [currentEmailAccountId, provider], ); From b7ed2b6d5ce2c3211e5b0ef777953c698369e4b7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:16:14 +0300 Subject: [PATCH 07/25] more outlook cleanup. delete old files --- apps/web/__tests__/ai-example-matches.test.ts | 106 -------------- apps/web/app/api/google/watch/all/route.ts | 11 +- apps/web/utils/actions/rule.ts | 98 ++++--------- apps/web/utils/actions/rule.validation.ts | 11 -- .../example-matches/find-example-matches.ts | 136 ------------------ apps/web/utils/cold-email/is-cold-email.ts | 15 +- apps/web/utils/email/google.ts | 5 + apps/web/utils/email/microsoft.ts | 5 + apps/web/utils/email/types.ts | 7 +- 9 files changed, 53 insertions(+), 341 deletions(-) delete mode 100644 apps/web/__tests__/ai-example-matches.test.ts delete mode 100644 apps/web/utils/ai/example-matches/find-example-matches.ts diff --git a/apps/web/__tests__/ai-example-matches.test.ts b/apps/web/__tests__/ai-example-matches.test.ts deleted file mode 100644 index 1952d1c0f9..0000000000 --- a/apps/web/__tests__/ai-example-matches.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import type { gmail_v1 } from "@googleapis/gmail"; -import { aiFindExampleMatches } from "@/utils/ai/example-matches/find-example-matches"; -import { queryBatchMessages } from "@/utils/gmail/message"; -import type { ParsedMessage } from "@/utils/types"; -import { findExampleMatchesSchema } from "@/utils/ai/example-matches/find-example-matches"; -import { getEmailAccount } from "@/__tests__/helpers"; - -// pnpm test-ai ai-find-example-matches - -const TIMEOUT = 15_000; - -const isAiTest = process.env.RUN_AI_TESTS === "true"; - -vi.mock("server-only", () => ({})); -vi.mock("@/utils/gmail/message", () => ({ - queryBatchMessages: vi.fn(), -})); - -describe.runIf(isAiTest)("aiFindExampleMatches", () => { - it( - "should find example matches based on user prompt", - async () => { - const emailAccount = getEmailAccount(); - - const gmail = {} as gmail_v1.Gmail; - const rulesPrompt = ` -* Label newsletters as "Newsletter" and archive them. -* Label emails that require a reply as "Reply Required". -* If a customer asks to set up a call, send them my calendar link: https://cal.com/example -`.trim(); - - const mockMessages = [ - { - id: "msg1", - threadId: "thread1", - headers: { - from: "newsletter@company.com", - subject: "Weekly Industry Digest", - }, - snippet: "Top stories in our industry this week...", - }, - { - id: "msg2", - threadId: "thread2", - headers: { - from: "client@example.com", - subject: "Urgent: Need your input", - }, - snippet: - "Could you please review this proposal and get back to me...", - }, - { - id: "msg3", - threadId: "thread3", - headers: { - from: "customer@potential.com", - subject: "Interested in setting up a call", - }, - snippet: "I'd like to discuss your services. Can we schedule a call?", - }, - ]; - - vi.mocked(queryBatchMessages).mockResolvedValue({ - messages: mockMessages as ParsedMessage[], - nextPageToken: null, - }); - - const result = await aiFindExampleMatches( - emailAccount, - gmail, - rulesPrompt, - ); - - expect(result).toEqual( - expect.objectContaining({ - matches: expect.arrayContaining([ - expect.objectContaining({ - emailId: expect.any(String), - rule: expect.stringContaining("Newsletter"), - }), - expect.objectContaining({ - emailId: expect.any(String), - rule: expect.stringContaining("Reply Required"), - }), - expect.objectContaining({ - emailId: expect.any(String), - rule: expect.stringContaining("calendar link"), - }), - ]), - }), - ); - - expect(result.matches).toHaveLength(3); - expect(findExampleMatchesSchema.safeParse(result).success).toBe(true); - expect(queryBatchMessages).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - maxResults: expect.any(Number), - }), - ); - }, - TIMEOUT, - ); // Increased timeout for AI call -}); diff --git a/apps/web/app/api/google/watch/all/route.ts b/apps/web/app/api/google/watch/all/route.ts index 0a30934fa3..b23415d6e6 100644 --- a/apps/web/app/api/google/watch/all/route.ts +++ b/apps/web/app/api/google/watch/all/route.ts @@ -1,12 +1,11 @@ import { NextResponse } from "next/server"; -import { getGmailClientWithRefresh } from "@/utils/gmail/client"; import prisma from "@/utils/prisma"; -import { watchEmails } from "@/app/api/google/watch/controller"; import { hasCronSecret, hasPostCronSecret } from "@/utils/cron"; import { withError } from "@/utils/middleware"; import { captureException } from "@/utils/error"; import { hasAiAccess } from "@/utils/premium"; import { createScopedLogger } from "@/utils/logger"; +import { createEmailProvider } from "@/utils/email/provider"; const logger = createScopedLogger("api/google/watch/all"); @@ -99,14 +98,12 @@ async function watchAllEmails() { continue; } - const gmail = await getGmailClientWithRefresh({ - accessToken: emailAccount.account.access_token, - refreshToken: emailAccount.account.refresh_token, - expiresAt: emailAccount.account.expires_at?.getTime() || null, + const emailProvider = await createEmailProvider({ emailAccountId: emailAccount.id, + provider: "google", }); - await watchEmails({ emailAccountId: emailAccount.id, gmail }); + await emailProvider.watchEmails(); } catch (error) { if (error instanceof Error) { const warn = [ diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index fb9ba515ce..38cd90a863 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -5,8 +5,6 @@ import { after } from "next/server"; import { createRuleBody, updateRuleBody, - updateRuleInstructionsBody, - rulesExamplesBody, updateRuleSettingsBody, enableDraftRepliesBody, deleteRuleBody, @@ -16,7 +14,6 @@ import { } from "@/utils/actions/rule.validation"; import prisma from "@/utils/prisma"; import { isDuplicateError, isNotFoundError } from "@/utils/prisma-helpers"; -import { aiFindExampleMatches } from "@/utils/ai/example-matches/find-example-matches"; import { flattenConditions } from "@/utils/condition"; import { ActionType, @@ -39,15 +36,11 @@ import { INTERNAL_API_KEY_HEADER } from "@/utils/internal-api"; import type { ProcessPreviousBody } from "@/app/api/reply-tracker/process-previous/route"; import { RuleName, SystemRule } from "@/utils/rule/consts"; import { actionClient } from "@/utils/actions/safe-action"; -import { - getGmailClientForEmail, - getOutlookClientForEmail, -} from "@/utils/account"; -import { getEmailAccountWithAi } from "@/utils/user/get"; import { prefixPath } from "@/utils/path"; import { createRuleHistory } from "@/utils/rule/rule-history"; import { ONE_WEEK_MINUTES } from "@/utils/date"; import { getOrCreateOutlookFolderIdByName } from "@/utils/outlook/folders"; +import { createEmailProvider } from "@/utils/email/provider"; function getCategoryActionDescription(categoryAction: CategoryAction): string { switch (categoryAction) { @@ -89,9 +82,11 @@ async function getActionsFromCategoryAction( } case "move_folder": case "move_folder_delayed": { - const outlook = await getOutlookClientForEmail({ emailAccountId }); - const folderId = await getOrCreateOutlookFolderIdByName( - outlook, + const emailProvider = await createEmailProvider({ + emailAccountId, + provider: "outlook", + }); + const folderId = await emailProvider.getOrCreateOutlookFolderIdByName( rule.name, ); actions = [ @@ -121,7 +116,7 @@ export const createRuleAction = actionClient .schema(createRuleBody) .action( async ({ - ctx: { emailAccountId, provider, logger }, + ctx: { emailAccountId, logger }, parsedInput: { name, automate, @@ -356,23 +351,6 @@ export const updateRuleAction = actionClient }, ); -export const updateRuleInstructionsAction = actionClient - .metadata({ name: "updateRuleInstructions" }) - .schema(updateRuleInstructionsBody) - .action( - async ({ ctx: { emailAccountId }, parsedInput: { id, instructions } }) => { - const currentRule = await prisma.rule.findUnique({ - where: { id, emailAccountId }, - include: { actions: true, categoryFilters: true, group: true }, - }); - if (!currentRule) throw new SafeError("Rule not found"); - - revalidatePath(prefixPath(emailAccountId, `/assistant/rule/${id}`)); - revalidatePath(prefixPath(emailAccountId, "/assistant")); - revalidatePath(prefixPath(emailAccountId, "/automation")); - }, - ); - export const updateRuleSettingsAction = actionClient .metadata({ name: "updateRuleSettings" }) .schema(updateRuleSettingsBody) @@ -444,47 +422,27 @@ export const enableDraftRepliesAction = actionClient export const deleteRuleAction = actionClient .metadata({ name: "deleteRule" }) .schema(deleteRuleBody) - .action( - async ({ ctx: { emailAccountId, provider }, parsedInput: { id } }) => { - const rule = await prisma.rule.findUnique({ - where: { id, emailAccountId }, - include: { actions: true, categoryFilters: true, group: true }, + .action(async ({ ctx: { emailAccountId }, parsedInput: { id } }) => { + const rule = await prisma.rule.findUnique({ + where: { id, emailAccountId }, + include: { actions: true, categoryFilters: true, group: true }, + }); + if (!rule) return; // already deleted + if (rule.emailAccountId !== emailAccountId) + throw new SafeError("You don't have permission to delete this rule"); + + try { + await deleteRule({ + ruleId: id, + emailAccountId, + groupId: rule.groupId, }); - if (!rule) return; // already deleted - if (rule.emailAccountId !== emailAccountId) - throw new SafeError("You don't have permission to delete this rule"); - - try { - await deleteRule({ - ruleId: id, - emailAccountId, - groupId: rule.groupId, - }); - revalidatePath(prefixPath(emailAccountId, `/assistant/rule/${id}`)); - } catch (error) { - if (isNotFoundError(error)) return; - throw error; - } - }, - ); - -export const getRuleExamplesAction = actionClient - .metadata({ name: "getRuleExamples" }) - .schema(rulesExamplesBody) - .action(async ({ ctx: { emailAccountId }, parsedInput: { rulesPrompt } }) => { - const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - if (!emailAccount) throw new SafeError("Email account not found"); - - const gmail = await getGmailClientForEmail({ emailAccountId }); - - const { matches } = await aiFindExampleMatches( - emailAccount, - gmail, - rulesPrompt, - ); - - return { matches }; + revalidatePath(prefixPath(emailAccountId, `/assistant/rule/${id}`)); + } catch (error) { + if (isNotFoundError(error)) return; + throw error; + } }); export const createRulesOnboardingAction = actionClient @@ -517,7 +475,7 @@ export const createRulesOnboardingAction = actionClient }); if (!emailAccount) throw new SafeError("User not found"); - const promises: Promise[] = []; + const promises: Promise[] = []; const isSet = ( value: string | undefined | null, @@ -629,8 +587,6 @@ export const createRulesOnboardingAction = actionClient ); })(); promises.push(promise); - - // TODO: prompt file update } else { const promise = (async () => { const actions = await getActionsFromCategoryAction( diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 72fc631c5b..27ad9d6da5 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -163,23 +163,12 @@ export type UpdateRuleBody = z.infer; export const deleteRuleBody = z.object({ id: z.string() }); -export const updateRuleInstructionsBody = z.object({ - id: z.string(), - instructions: z.string(), -}); -export type UpdateRuleInstructionsBody = z.infer< - typeof updateRuleInstructionsBody ->; - export const saveRulesPromptBody = z.object({ rulesPrompt: z.string().trim() }); export type SaveRulesPromptBody = z.infer; export const createRulesBody = z.object({ prompt: z.string().trim() }); export type CreateRulesBody = z.infer; -export const rulesExamplesBody = z.object({ rulesPrompt: z.string() }); -export type RulesExamplesBody = z.infer; - export const updateRuleSettingsBody = z.object({ id: z.string(), instructions: z.string(), diff --git a/apps/web/utils/ai/example-matches/find-example-matches.ts b/apps/web/utils/ai/example-matches/find-example-matches.ts deleted file mode 100644 index c53cffe6c5..0000000000 --- a/apps/web/utils/ai/example-matches/find-example-matches.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { stepCountIs, tool } from "ai"; -import { z } from "zod"; -import type { gmail_v1 } from "@googleapis/gmail"; -import { createGenerateText } from "@/utils/llms"; -import type { EmailAccountWithAI } from "@/utils/llms/types"; -import { queryBatchMessages } from "@/utils/gmail/message"; -import { getModel } from "@/utils/llms/model"; - -const FIND_EXAMPLE_MATCHES = "findExampleMatches"; - -export const findExampleMatchesSchema = z.object({ - matches: z - .array( - z.object({ - emailId: z.string().describe("The email ID of a matching email."), - rule: z.string().describe("The specific rule that the email matches."), - reason: z - .string() - .describe( - "Explanation of why this email is a definite match for the rule.", - ), - // isMatch: z.boolean().describe("Must be true. Only include if this is a definite match for the rule."), - }), - ) - .describe( - "Only include emails that definitely match the rules. Do not include non-matches or uncertain matches.", - ), -}); - -export async function aiFindExampleMatches( - emailAccount: EmailAccountWithAI, - gmail: gmail_v1.Gmail, - rulesPrompt: string, -) { - const system = - "You are an AI assistant specializing in email management and organization. Your task is to find example emails that match the given rules with high confidence."; - - const prompt = `Find high-confidence example matches for the rules prompt: - -${rulesPrompt} - - -Critical instructions: -1. Analyze each email carefully. Only return matches you are absolutely certain follow these guidelines. -2. Quality over quantity is crucial. It's better to return no matches than to include incorrect or uncertain ones. - - Only return matches that you are 100% confident about. - - Aim for a few high-confidence matches per rule. - - Even a single high-confidence match is valuable. -3. You must strictly differentiate between emails that initiate an action and emails that confirm an action has already occurred. - - If a rule mentions "asks to", "requests to", or similar phrases indicating initiation, only match emails that contain the initial request. Do not match confirmation emails for these rules. - - Confirmation emails (e.g., "Your meeting is scheduled") are not matches for rules about initiating actions, even if they relate to the same topic. - -Example: -- Rule: "If a customer asks to set up a call, send them my calendar link" - - Match: An email saying "Can we schedule a call next week?" - - Do not match: An email saying "Your call is scheduled for Tuesday at 2 PM" - -Use the listEmails tool to fetch emails and the findExampleMatches tool to return results. -If no high-confidence matches are found for any rule, it's acceptable to return an empty result. - -Please proceed step-by-step, fetching emails and analyzing them to find only the most certain matches for the given rules. -Remember, precision is crucial - only include matches you are absolutely sure about.`; - - const listedEmails: Record< - string, - { emailId: string; from: string; subject: string; snippet: string } - > = {}; - - const listEmailsTool = (gmail: gmail_v1.Gmail) => ({ - description: "List email messages. Returns max 20 results.", - inputSchema: z.object({ - query: z.string().optional().describe("Optional Gmail search query."), - }), - execute: async ({ query }: { query: string | undefined }) => { - const { messages } = await queryBatchMessages(gmail, { - query: `${query || ""} -label:sent`.trim(), - maxResults: 20, - }); - - const results = messages.map((message) => ({ - emailId: message.id, - from: message.headers.from, - subject: message.headers.subject, - snippet: message.snippet, - })); - - for (const result of results) { - listedEmails[result.emailId] = result; - } - - return results; - }, - }); - - const modelOptions = getModel(emailAccount.user, "chat"); - - const generateText = createGenerateText({ - userEmail: emailAccount.email, - label: "Find example matches", - modelOptions, - }); - - const aiResponse = await generateText({ - ...modelOptions, - system, - prompt, - stopWhen: stepCountIs(10), - tools: { - listEmails: listEmailsTool(gmail), - [FIND_EXAMPLE_MATCHES]: tool({ - description: "Find example matches", - inputSchema: findExampleMatchesSchema, - }), - }, - }); - - const findExampleMatchesToolCalls = aiResponse.toolCalls.filter( - ({ toolName }) => toolName === FIND_EXAMPLE_MATCHES, - ); - - const matches = findExampleMatchesToolCalls.reduce< - z.infer["matches"] - >((acc, { input }) => { - const typedArgs = input as z.infer; - return acc.concat(typedArgs.matches); - }, []); - - return { - matches: matches - .filter((match) => listedEmails[match.emailId]) - .map((match) => ({ - ...listedEmails[match.emailId], - rule: match.rule, - })), - }; -} diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index 1cedcaefbb..ee270a2445 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -14,8 +14,9 @@ import type { EmailForLLM } from "@/utils/types"; import type { EmailProvider } from "@/utils/email/types"; import { getModel, type ModelType } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; -import { getOrCreateOutlookFolderIdByName } from "@/utils/outlook/folders"; -import { getOutlookClientForEmail } from "@/utils/account"; +import { createEmailProvider } from "@/utils/email/provider"; + +export const COLD_EMAIL_FOLDER_NAME = "Cold Emails"; const logger = createScopedLogger("ai-cold-email"); @@ -243,13 +244,13 @@ export async function blockColdEmail(options: { // For archiving and marking as read, we'll need to implement these in the provider if (shouldArchive) { if (provider.name === "microsoft") { - const outlook = await getOutlookClientForEmail({ + const emailProvider = await createEmailProvider({ emailAccountId: emailAccount.id, + provider: "outlook", }); - // TODO: move "Cold Emails"toa const or allow the user to set the folder - const folderId = await getOrCreateOutlookFolderIdByName( - outlook, - "Cold Emails", + + const folderId = await emailProvider.getOrCreateOutlookFolderIdByName( + COLD_EMAIL_FOLDER_NAME, ); await provider.moveThreadToFolder( email.threadId, diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index ed382979b6..cd3a93827a 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -881,4 +881,9 @@ export class GmailProvider implements EmailProvider { ): Promise { logger.warn("Moving thread to folder is not supported for Gmail"); } + + async getOrCreateOutlookFolderIdByName(_folderName: string): Promise { + logger.warn("Moving thread to folder is not supported for Gmail"); + return ""; + } } diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 27308ceb24..68c0eec965 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -60,6 +60,7 @@ import type { import { unwatchOutlook, watchOutlook } from "@/utils/outlook/watch"; import { escapeODataString } from "@/utils/outlook/odata-escape"; import { extractEmailAddress } from "@/utils/email"; +import { getOrCreateOutlookFolderIdByName } from "@/utils/outlook/folders"; const logger = createScopedLogger("outlook-provider"); @@ -1095,4 +1096,8 @@ export class OutlookProvider implements EmailProvider { throw error; } } + + async getOrCreateOutlookFolderIdByName(folderName: string): Promise { + return await getOrCreateOutlookFolderIdByName(this.client, folderName); + } } diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index dc52ba6128..8a9e2ad857 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -135,13 +135,13 @@ export interface EmailProvider { from: string; addLabelIds?: string[]; removeLabelIds?: string[]; - }): Promise; - deleteFilter(id: string): Promise; + }): Promise; + deleteFilter(id: string): Promise; createAutoArchiveFilter(options: { from: string; gmailLabelId?: string; labelName?: string; - }): Promise; + }): Promise; getMessagesWithPagination(options: { query?: string; maxResults?: number; @@ -211,4 +211,5 @@ export interface EmailProvider { ownerEmail: string, folderName: string, ): Promise; + getOrCreateOutlookFolderIdByName(folderName: string): Promise; } From b6eff340235fda9b38fe745e1fddbb56f60305f3 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:23:30 +0300 Subject: [PATCH 08/25] fix outlook name --- .../app/(app)/[emailAccountId]/clean/onboarding/page.tsx | 2 +- apps/web/utils/actions/rule.ts | 3 +-- apps/web/utils/cold-email/is-cold-email.ts | 8 +------- apps/web/utils/email/provider.ts | 3 ++- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx index 23c3cdb7db..c6d534402b 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx @@ -37,7 +37,7 @@ export default async function CleanPage(props: { const emailProvider = await createEmailProvider({ emailAccountId, - provider: emailAccount?.account.provider ?? null, + provider: emailAccount?.account.provider ?? "google", }); const { unhandledCount } = await getUnhandledCount(emailProvider); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 38cd90a863..7d79a6845d 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -39,7 +39,6 @@ import { actionClient } from "@/utils/actions/safe-action"; import { prefixPath } from "@/utils/path"; import { createRuleHistory } from "@/utils/rule/rule-history"; import { ONE_WEEK_MINUTES } from "@/utils/date"; -import { getOrCreateOutlookFolderIdByName } from "@/utils/outlook/folders"; import { createEmailProvider } from "@/utils/email/provider"; function getCategoryActionDescription(categoryAction: CategoryAction): string { @@ -84,7 +83,7 @@ async function getActionsFromCategoryAction( case "move_folder_delayed": { const emailProvider = await createEmailProvider({ emailAccountId, - provider: "outlook", + provider: "microsoft", }); const folderId = await emailProvider.getOrCreateOutlookFolderIdByName( rule.name, diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index ee270a2445..a324ba689f 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -14,7 +14,6 @@ import type { EmailForLLM } from "@/utils/types"; import type { EmailProvider } from "@/utils/email/types"; import { getModel, type ModelType } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; -import { createEmailProvider } from "@/utils/email/provider"; export const COLD_EMAIL_FOLDER_NAME = "Cold Emails"; @@ -244,12 +243,7 @@ export async function blockColdEmail(options: { // For archiving and marking as read, we'll need to implement these in the provider if (shouldArchive) { if (provider.name === "microsoft") { - const emailProvider = await createEmailProvider({ - emailAccountId: emailAccount.id, - provider: "outlook", - }); - - const folderId = await emailProvider.getOrCreateOutlookFolderIdByName( + const folderId = await provider.getOrCreateOutlookFolderIdByName( COLD_EMAIL_FOLDER_NAME, ); await provider.moveThreadToFolder( diff --git a/apps/web/utils/email/provider.ts b/apps/web/utils/email/provider.ts index 88b63943ea..f72f9782bb 100644 --- a/apps/web/utils/email/provider.ts +++ b/apps/web/utils/email/provider.ts @@ -15,7 +15,7 @@ export async function createEmailProvider({ provider, }: { emailAccountId: string; - provider: string | null; + provider: string; }): Promise { if (isGoogleProvider(provider)) { const client = await getGmailClientForEmail({ emailAccountId }); @@ -24,5 +24,6 @@ export async function createEmailProvider({ const client = await getOutlookClientForEmail({ emailAccountId }); return new OutlookProvider(client); } + throw new Error(`Unsupported provider: ${provider}`); } From 698bf7880bdfe3937a979af8a489fab3362d4cf5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:31:09 +0300 Subject: [PATCH 09/25] clean up ui --- .../app/(app)/[emailAccountId]/assistant/ProcessRules.tsx | 4 ---- apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx | 6 +----- apps/web/utils/ai/actions.ts | 3 ++- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx index 7f2c752d01..b57bba24e1 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx @@ -24,7 +24,6 @@ import { Table, TableBody, TableRow, TableCell } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; import { SearchForm } from "@/components/SearchForm"; -import { Badge } from "@/components/Badge"; import type { BatchExecutedRulesResponse } from "@/app/api/user/executed-rules/batch/route"; import { isAIRule, @@ -370,9 +369,6 @@ function ProcessRulesRow({ {result ? ( <>
- {result.existing && ( - Already processed - )}
diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 318856fb49..29e5fe01b3 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -6,6 +6,7 @@ import { coordinateReplyProcess } from "@/utils/reply-tracker/inbound"; import { internalDateToDate } from "@/utils/date"; import type { EmailProvider } from "@/utils/email/types"; import { enqueueDigestItem } from "@/utils/digest/index"; +import { filterNullProperties } from "@/utils"; const logger = createScopedLogger("ai-actions"); @@ -35,7 +36,7 @@ export const runActionFunction = async (options: { userEmail, id: action.id, }); - logger.trace("Running action:", action); + logger.trace("Running action", () => filterNullProperties(action)); const { type, ...args } = action; const opts = { From c65a0d14bf2719ad8e92e179513e83b3f8b319ea Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:51:01 +0300 Subject: [PATCH 10/25] fix up drafting for outlook and dont rely on raw query --- apps/web/utils/__mocks__/email-provider.ts | 5 + .../utils/ai/reply/reply-context-collector.ts | 2 + apps/web/utils/email/google.ts | 33 ++-- apps/web/utils/email/microsoft.ts | 62 +++++-- apps/web/utils/email/types.ts | 6 +- apps/web/utils/outlook/message.ts | 164 ++++++++---------- apps/web/utils/outlook/thread.ts | 43 +++-- 7 files changed, 182 insertions(+), 133 deletions(-) diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts index 151e8f61bf..8446655fb3 100644 --- a/apps/web/utils/__mocks__/email-provider.ts +++ b/apps/web/utils/__mocks__/email-provider.ts @@ -123,6 +123,11 @@ export const createMockEmailProvider = ( getThreadsFromSenderWithSubject: vi.fn().mockResolvedValue([]), processHistory: vi.fn().mockResolvedValue(undefined), moveThreadToFolder: vi.fn().mockResolvedValue(undefined), + getMessagesByFields: vi + .fn() + .mockResolvedValue({ messages: [], nextPageToken: undefined }), + getOrCreateOutlookFolderIdByName: vi.fn().mockResolvedValue("folder1"), + sendEmailWithHtml: vi.fn().mockResolvedValue(undefined), ...overrides, }); diff --git a/apps/web/utils/ai/reply/reply-context-collector.ts b/apps/web/utils/ai/reply/reply-context-collector.ts index 09e928adf6..12589763bb 100644 --- a/apps/web/utils/ai/reply/reply-context-collector.ts +++ b/apps/web/utils/ai/reply/reply-context-collector.ts @@ -140,6 +140,8 @@ ${getTodayForLLM()}`; error, errorMessage, query, + emailProvider: emailProvider.name, + afterDate: sixMonthsAgo.toISOString(), }); return { success: false, diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index cd3a93827a..e3d583a3bc 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -1,5 +1,9 @@ import type { gmail_v1 } from "@googleapis/gmail"; -import type { ParsedMessage } from "@/utils/types"; +import { + isDefined, + type MessageWithPayload, + type ParsedMessage, +} from "@/utils/types"; import { parseMessage } from "@/utils/gmail/message"; import { getMessage, @@ -146,15 +150,19 @@ export class GmailProvider implements EmailProvider { return parseMessage(message); } - async getMessages(query?: string, maxResults = 50): Promise { + async getMessages(options?: { + searchQuery?: string; + folderId?: string; + maxResults?: number; + }): Promise { const response = await getMessages(this.client, { - query, - maxResults, + query: options?.searchQuery, + maxResults: options?.maxResults ?? 50, }); const messages = response.messages || []; return messages - .filter((message) => message.payload) - .map((message) => parseMessage(message as any)); + .filter((message) => isDefined(message.payload)) + .map((message) => parseMessage(message as MessageWithPayload)); } async getSentMessages(maxResults = 20): Promise { @@ -331,7 +339,7 @@ export class GmailProvider implements EmailProvider { content: string; contentType: string; }>; - }): Promise { + }) { return await sendEmailWithHtml(this.client, body); } @@ -462,14 +470,14 @@ export class GmailProvider implements EmailProvider { from: string; addLabelIds?: string[]; removeLabelIds?: string[]; - }): Promise { + }) { return createFilter({ gmail: this.client, ...options }); } async createAutoArchiveFilter(options: { from: string; gmailLabelId?: string; - }): Promise { + }) { return createAutoArchiveFilter({ gmail: this.client, from: options.from, @@ -477,7 +485,7 @@ export class GmailProvider implements EmailProvider { }); } - async deleteFilter(id: string): Promise { + async deleteFilter(id: string) { return deleteFilter({ gmail: this.client, id }); } @@ -783,8 +791,9 @@ export class GmailProvider implements EmailProvider { const emailThread: EmailThread = { id, messages: - thread.messages?.map((message) => parseMessage(message as any)) || - [], + thread.messages?.map((message) => + parseMessage(message as MessageWithPayload), + ) || [], snippet: decodeSnippet(thread.snippet), historyId: thread.historyId || undefined, }; diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 68c0eec965..a37606a79a 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -73,7 +73,7 @@ export class OutlookProvider implements EmailProvider { } async getThreads(folderId?: string): Promise { - const messages = await this.getMessages(folderId); + const messages = await this.getMessages({ folderId }); const threadMap = new Map(); messages.forEach((message) => { @@ -92,7 +92,9 @@ export class OutlookProvider implements EmailProvider { } async getThread(threadId: string): Promise { - const messages = await this.getMessages(`conversationId:${threadId}`); + const messages = await this.getMessages({ + searchQuery: `conversationId:${threadId}`, + }); return { id: threadId, messages, @@ -118,14 +120,20 @@ export class OutlookProvider implements EmailProvider { return getMessage(messageId, this.client); } - async getMessages(query?: string, maxResults = 50): Promise { + async getMessages(options?: { + searchQuery?: string; + folderId?: string; + maxResults?: number; + }): Promise { + const maxResults = options?.maxResults ?? 50; const allMessages: ParsedMessage[] = []; let pageToken: string | undefined; const pageSize = 20; // Outlook API limit while (allMessages.length < maxResults) { const response = await queryBatchMessages(this.client, { - query, + searchQuery: options?.searchQuery, + folderId: options?.folderId, maxResults: Math.min(pageSize, maxResults - allMessages.length), pageToken, }); @@ -410,7 +418,7 @@ export class OutlookProvider implements EmailProvider { async getPreviousConversationMessages( messageIds: string[], ): Promise { - return this.getThreadMessages(messageIds[0]); + return this.getMessagesBatch(messageIds); } async removeThreadLabel(threadId: string, labelId: string): Promise { @@ -519,24 +527,33 @@ export class OutlookProvider implements EmailProvider { messages: ParsedMessage[]; nextPageToken?: string; }> { - // For Outlook, we need to handle date filtering differently - // Microsoft Graph API uses different date filtering syntax - let query = options.query || ""; + logger.info("getMessagesWithPagination called", { + query: options.query, + maxResults: options.maxResults, + pageToken: options.pageToken, + before: options.before?.toISOString(), + after: options.after?.toISOString(), + }); + + // For Outlook, separate search queries from date filters + // Microsoft Graph API handles these differently + const originalQuery = options.query || ""; - // Build date filter for Outlook + // Build date filter for Outlook (always quoted for OData) const dateFilters: string[] = []; if (options.before) { - dateFilters.push(`receivedDateTime lt ${options.before.toISOString()}`); + dateFilters.push(`receivedDateTime lt '${options.before.toISOString()}'`); } if (options.after) { - dateFilters.push(`receivedDateTime gt ${options.after.toISOString()}`); + dateFilters.push(`receivedDateTime gt '${options.after.toISOString()}'`); } - // Combine date filters with existing query - if (dateFilters.length > 0) { - const dateFilter = dateFilters.join(" and "); - query = query ? `${query} and ${dateFilter}` : dateFilter; - } + logger.info("Query parameters separated", { + originalQuery, + dateFilters, + hasSearchQuery: !!originalQuery.trim(), + hasDateFilters: dateFilters.length > 0, + }); // Get folder IDs to get the inbox folder ID const folderIds = await getFolderIds(this.client); @@ -546,11 +563,20 @@ export class OutlookProvider implements EmailProvider { throw new Error("Could not find inbox folder ID"); } + logger.info("Calling queryBatchMessages with separated parameters", { + searchQuery: originalQuery.trim() || undefined, + dateFilters, + maxResults: options.maxResults || 20, + pageToken: options.pageToken, + folderId: inboxFolderId, + }); + const response = await queryBatchMessages(this.client, { - query: query.trim() || undefined, + searchQuery: originalQuery.trim() || undefined, + dateFilters, maxResults: options.maxResults || 20, pageToken: options.pageToken, - folderId: inboxFolderId, // Pass the inbox folder ID to match original behavior + folderId: inboxFolderId, }); return { diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 8a9e2ad857..672f27c1ae 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -40,7 +40,11 @@ export interface EmailProvider { getLabels(): Promise; getLabelById(labelId: string): Promise; getMessage(messageId: string): Promise; - getMessages(query?: string, maxResults?: number): Promise; + getMessages(options?: { + searchQuery?: string; + folderId?: string; + maxResults?: number; + }): Promise; getMessagesByFields(options: { froms?: string[]; subjects?: string[]; diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index 9466583d6b..04a2033b67 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -107,13 +107,14 @@ function getOutlookLabels( export async function queryBatchMessages( client: OutlookClient, options: { - query?: string; + searchQuery?: string; // Pure search query + dateFilters?: string[]; // Array of OData date filters maxResults?: number; pageToken?: string; folderId?: string; }, ) { - const { query, pageToken, folderId } = options; + const { searchQuery, dateFilters, pageToken, folderId } = options; const MAX_RESULTS = 20; @@ -133,7 +134,8 @@ export async function queryBatchMessages( logger.info("Building Outlook request", { maxResults, - hasQuery: !!query, + hasSearchQuery: !!searchQuery, + hasDateFilters: !!(dateFilters && dateFilters.length > 0), pageToken, folderId, }); @@ -149,16 +151,9 @@ export async function queryBatchMessages( let nextPageToken: string | undefined; - // Check if query is an OData filter (contains operators like eq, gt, lt, etc.) - const isODataFilter = - query?.includes(" eq ") || - query?.includes(" gt ") || - query?.includes(" lt ") || - query?.includes(" ge ") || - query?.includes(" le ") || - query?.includes(" ne ") || - query?.includes(" and ") || - query?.includes(" or "); + // Determine if we have a search query vs pure filters + const hasSearchQuery = !!searchQuery?.trim(); + const hasDateFilters = !!(dateFilters && dateFilters.length > 0); // Always filter to only include inbox and archive folders const inboxFolderId = folderIds.inbox; @@ -171,103 +166,92 @@ export async function queryBatchMessages( }); } - if (query?.trim()) { - if (isODataFilter) { - // Filter path - use filter and skipToken - // Combine the existing filter with folder restrictions unless the query already constrains parentFolderId - const hasFolderConstraint = query.includes("parentFolderId"); - const folderFilter = `(parentFolderId eq '${inboxFolderId}' or parentFolderId eq '${archiveFolderId}')`; - const combinedFilter = hasFolderConstraint - ? query.trim() - : query.trim() - ? `${query.trim()} and ${folderFilter}` - : folderFilter; - request = request.filter(combinedFilter); - - if (pageToken) { - request = request.skipToken(pageToken); - } + // Build folder filter for all cases + const folderFilter = folderId + ? `parentFolderId eq '${folderId}'` + : `(parentFolderId eq '${inboxFolderId}' or parentFolderId eq '${archiveFolderId}')`; - const response: { value: Message[]; "@odata.nextLink"?: string } = - await request.get(); - const messages = await convertMessages(response.value, folderIds); + if (hasSearchQuery) { + // Search path - use $search parameter + logger.info("Using search path", { + searchQuery, + folderFilter, + }); - // For filter, get next page token from @odata.nextLink - nextPageToken = response["@odata.nextLink"] - ? new URL(response["@odata.nextLink"]).searchParams.get("$skiptoken") || - undefined - : undefined; + request = request.search(searchQuery!.trim()); - logger.info("Filter results", { - messageCount: messages.length, - hasNextPageToken: !!nextPageToken, - }); + // Apply folder filtering via post-processing since $search can't be combined with $filter + if (pageToken) { + request = request.skipToken(pageToken); + } - return { messages, nextPageToken }; - } else { - // Search path - use search and skipToken - request = request.search(query.trim()); + const response: { value: Message[]; "@odata.nextLink"?: string } = + await request.get(); - if (pageToken) { - request = request.skipToken(pageToken); + // Filter messages to only include inbox and archive folders + const filteredMessages = response.value.filter((message) => { + if (folderId) { + return message.parentFolderId === folderId; } - - const response: { value: Message[]; "@odata.nextLink"?: string } = - await request.get(); - // Filter messages to only include inbox and archive folders - const filteredMessages = response.value.filter( - (message) => - message.parentFolderId === inboxFolderId || - message.parentFolderId === archiveFolderId, + return ( + message.parentFolderId === inboxFolderId || + message.parentFolderId === archiveFolderId ); - const messages = await convertMessages(filteredMessages, folderIds); + }); + const messages = await convertMessages(filteredMessages, folderIds); - // For search, get next page token from @odata.nextLink - nextPageToken = response["@odata.nextLink"] - ? new URL(response["@odata.nextLink"]).searchParams.get("$skiptoken") || - undefined - : undefined; + nextPageToken = response["@odata.nextLink"] + ? new URL(response["@odata.nextLink"]).searchParams.get("$skiptoken") || + undefined + : undefined; - logger.info("Search results", { - messageCount: messages.length, - hasNextPageToken: !!nextPageToken, - }); + logger.info("Search results", { + totalFound: response.value.length, + afterFolderFiltering: filteredMessages.length, + messageCount: messages.length, + hasNextPageToken: !!nextPageToken, + }); - return { messages, nextPageToken }; - } + return { messages, nextPageToken }; } else { - // Non-search path - use filter, skip and orderBy - // Always filter to only include inbox and archive folders - const folderFilter = `(parentFolderId eq '${inboxFolderId}' or parentFolderId eq '${archiveFolderId}')`; + // Filter path - use $filter parameter for date filters or folder-only queries + const filters = [folderFilter]; - // If a specific folder is requested, override the default filter - if (folderId) { - request = request.filter(`parentFolderId eq '${folderId}'`); - } else { - request = request.filter(folderFilter); + // Add date filters if provided + if (hasDateFilters) { + filters.push(...dateFilters!); } - request = request - .skip(pageToken ? Number.parseInt(pageToken, 10) : 0) - .orderby("receivedDateTime DESC"); + const combinedFilter = filters.join(" and "); + + logger.info("Using filter path", { + folderFilter, + dateFilters: dateFilters || [], + combinedFilter, + }); + + request = request.filter(combinedFilter); + + if (pageToken) { + request = request.skipToken(pageToken); + } else { + // Only add orderby for non-paginated requests to avoid sorting complexity errors + request = request.orderby("receivedDateTime DESC"); + } - const response: { value: Message[] } = await request.get(); + const response: { value: Message[]; "@odata.nextLink"?: string } = + await request.get(); const messages = await convertMessages(response.value, folderIds); - // For non-search, calculate next page token based on message count - const hasMore = messages.length === maxResults; - nextPageToken = hasMore - ? (pageToken - ? Number.parseInt(pageToken, 10) + maxResults - : maxResults - ).toString() + nextPageToken = response["@odata.nextLink"] + ? new URL(response["@odata.nextLink"]).searchParams.get("$skiptoken") || + undefined : undefined; - logger.info("Non-search results", { + logger.info("Filter results", { messageCount: messages.length, - skip: pageToken ? Number.parseInt(pageToken, 10) : 0, - hasMore, - nextPageToken, + hasNextPageToken: !!nextPageToken, + combinedFilter, }); return { messages, nextPageToken }; diff --git a/apps/web/utils/outlook/thread.ts b/apps/web/utils/outlook/thread.ts index ca5ba0bafa..831fcf18aa 100644 --- a/apps/web/utils/outlook/thread.ts +++ b/apps/web/utils/outlook/thread.ts @@ -2,24 +2,43 @@ import type { OutlookClient } from "@/utils/outlook/client"; import type { Message } from "@microsoft/microsoft-graph-types"; import type { ParsedMessage } from "@/utils/types"; import { escapeODataString } from "@/utils/outlook/odata-escape"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("outlook/thread"); export async function getThread( threadId: string, client: OutlookClient, ): Promise { - const messages: { value: Message[] } = await client - .getClient() - .api("/me/messages") - .filter(`conversationId eq '${threadId}'`) - .top(100) // Get up to 100 messages instead of default 10 - .get(); + const escapedThreadId = escapeODataString(threadId); + const filter = `conversationId eq '${escapedThreadId}'`; + + try { + const messages: { value: Message[] } = await client + .getClient() + .api("/me/messages") + .filter(filter) + .top(100) // Get up to 100 messages instead of default 10 + .get(); - // Sort in memory to avoid "restriction or sort order is too complex" error - return messages.value.sort((a, b) => { - const dateA = new Date(a.receivedDateTime || 0).getTime(); - const dateB = new Date(b.receivedDateTime || 0).getTime(); - return dateB - dateA; // desc order (newest first) - }); + // Sort in memory to avoid "restriction or sort order is too complex" error + return messages.value.sort((a, b) => { + const dateA = new Date(a.receivedDateTime || 0).getTime(); + const dateB = new Date(b.receivedDateTime || 0).getTime(); + return dateB - dateA; // desc order (newest first) + }); + } catch (error) { + const err = error as any; + + logger.error("getThread failed", { + threadId, + filter, + error: error instanceof Error ? error.message : err, + errorCode: err?.code, + errorStatusCode: err?.statusCode, + }); + throw error; + } } export async function getThreads( From d033a037a4a85c117a8ef1287ccc6b5d731c6b5e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:53:35 +0300 Subject: [PATCH 11/25] fix call --- apps/web/utils/email/microsoft.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index a37606a79a..491c4eda0c 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -92,9 +92,7 @@ export class OutlookProvider implements EmailProvider { } async getThread(threadId: string): Promise { - const messages = await this.getMessages({ - searchQuery: `conversationId:${threadId}`, - }); + const messages = await this.getThreadMessages(threadId); return { id: threadId, messages, From c9b4f5789e3e3831590c909905919918b51a351f Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:16:02 +0300 Subject: [PATCH 12/25] fix build and try/catch --- apps/web/app/api/user/no-reply/route.ts | 2 +- apps/web/utils/actions/ai-rule.ts | 9 +++-- apps/web/utils/email/microsoft.ts | 48 ++++++++++++++++++++----- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/web/app/api/user/no-reply/route.ts b/apps/web/app/api/user/no-reply/route.ts index 0331a73622..c5e8ea0261 100644 --- a/apps/web/app/api/user/no-reply/route.ts +++ b/apps/web/app/api/user/no-reply/route.ts @@ -12,7 +12,7 @@ async function getNoReply({ }: { emailAccountId: string; userEmail: string; - provider: string | null; + provider: string; }) { const emailProvider = await createEmailProvider({ emailAccountId, diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 777fb33d51..ef68d28f30 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -40,9 +40,11 @@ export const runRulesAction = actionClient .schema(runRulesBody) .action( async ({ - ctx: { emailAccountId, provider, logger }, + ctx: { emailAccountId, provider, logger: ctxLogger }, parsedInput: { messageId, threadId, rerun, isTest }, }): Promise => { + const logger = ctxLogger.with({ messageId, threadId }); + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); if (!emailAccount) throw new Error("Email account not found"); @@ -75,10 +77,7 @@ export const runRulesAction = actionClient : null; if (executedRule) { - logger.info("Skipping. Rule already exists.", { - messageId, - threadId, - }); + logger.info("Skipping. Rule already exists."); return { rule: executedRule.rule, diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 491c4eda0c..c1e0564098 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -92,12 +92,22 @@ export class OutlookProvider implements EmailProvider { } async getThread(threadId: string): Promise { - const messages = await this.getThreadMessages(threadId); - return { - id: threadId, - messages, - snippet: messages[0]?.snippet || "", - }; + try { + const messages = await this.getThreadMessages(threadId); + + return { + id: threadId, + messages, + snippet: messages[0]?.snippet || "", + }; + } catch (error) { + logger.error("getThread failed", { + threadId, + error: error instanceof Error ? error.message : error, + errorCode: (error as any)?.code, + }); + throw error; + } } async getLabels(): Promise { @@ -115,7 +125,18 @@ export class OutlookProvider implements EmailProvider { } async getMessage(messageId: string): Promise { - return getMessage(messageId, this.client); + try { + const message = await getMessage(messageId, this.client); + return message; + } catch (error) { + const err = error as any; + logger.error("getMessage failed", { + messageId, + error: error instanceof Error ? error.message : error, + errorCode: err?.code, + }); + throw error; + } } async getMessages(options?: { @@ -368,7 +389,18 @@ export class OutlookProvider implements EmailProvider { } async getThreadMessages(threadId: string): Promise { - return getThreadMessages(threadId, this.client); + try { + const messages = await getThreadMessages(threadId, this.client); + return messages; + } catch (error) { + const err = error as any; + logger.error("getThreadMessages failed", { + threadId, + error: error instanceof Error ? error.message : error, + errorCode: err?.code, + }); + throw error; + } } async getThreadMessagesInInbox(threadId: string): Promise { From 62528612cd12cdd041e60331daa87c8461b2c97a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:29:53 +0300 Subject: [PATCH 13/25] fix up google query --- apps/web/utils/email/google.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index e3d583a3bc..5d8d0fdebc 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -724,11 +724,13 @@ export class GmailProvider implements EmailProvider { } if (type === "archive") { - queryParts.push(`-label:${GmailLabel.INBOX}`); + queryParts.push(`-in:${GmailLabel.INBOX}`); } if (excludeLabelNames) { - queryParts.push(`-in:"${excludeLabelNames.join(" ")}"`); + for (const labelName of excludeLabelNames) { + queryParts.push(`-label:"${labelName}"`); + } } return queryParts.length > 0 ? queryParts.join(" ") : undefined; From 2d95a34ea7273ff94496a83263ce34c236f290bd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:55:35 +0300 Subject: [PATCH 14/25] pr fixes --- .../clean/onboarding/page.tsx | 8 +++- .../cold-email-blocker/ColdEmailList.tsx | 2 +- apps/web/app/api/resend/digest/route.ts | 2 +- .../api/user/rules/[id]/example/controller.ts | 43 ++++++++----------- apps/web/utils/actions/rule.ts | 5 ++- apps/web/utils/ai/actions.ts | 29 +++++++------ apps/web/utils/email/google.ts | 9 ++++ apps/web/utils/email/microsoft.ts | 16 ++++++- apps/web/utils/email/types.ts | 3 +- apps/web/utils/outlook/message.ts | 4 +- 10 files changed, 75 insertions(+), 46 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx index c6d534402b..cc83967c60 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx @@ -1,4 +1,4 @@ -import { Card } from "@/components/ui/card"; +import { Card, CardTitle } from "@/components/ui/card"; import { IntroStep } from "@/app/(app)/[emailAccountId]/clean/IntroStep"; import { ActionSelectionStep } from "@/app/(app)/[emailAccountId]/clean/ActionSelectionStep"; import { CleanInstructionsStep } from "@/app/(app)/[emailAccountId]/clean/CleanInstructionsStep"; @@ -35,9 +35,13 @@ export default async function CleanPage(props: { }, }); + if (!emailAccount) { + return Email account not found; + } + const emailProvider = await createEmailProvider({ emailAccountId, - provider: emailAccount?.account.provider ?? "google", + provider: emailAccount.account.provider, }); const { unhandledCount } = await getUnhandledCount(emailProvider); diff --git a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx index 807d2deabe..61ec326031 100644 --- a/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx +++ b/apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailList.tsx @@ -137,7 +137,7 @@ function Row({ mutate: () => void; selected: Map; onToggleSelect: (id: string) => void; - markNotColdEmail: (input: { sender: string }) => Promise; + markNotColdEmail: (input: { sender: string }) => Promise; isExecuting: boolean; }) { return ( diff --git a/apps/web/app/api/resend/digest/route.ts b/apps/web/app/api/resend/digest/route.ts index a1aefd31d8..c2fea36135 100644 --- a/apps/web/app/api/resend/digest/route.ts +++ b/apps/web/app/api/resend/digest/route.ts @@ -114,7 +114,7 @@ async function sendEmail({ const emailProvider = await createEmailProvider({ emailAccountId, - provider: emailAccount?.account.provider ?? null, + provider: emailAccount.account.provider, }); const digestScheduleData = await getDigestSchedule({ emailAccountId }); diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts index bec1f4c4ce..f966c8e516 100644 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ b/apps/web/app/api/user/rules/[id]/example/controller.ts @@ -1,6 +1,3 @@ -import type { gmail_v1 } from "@googleapis/gmail"; -import { parseMessage } from "@/utils/gmail/message"; -import { getMessage, getMessages } from "@/utils/gmail/message"; import type { MessageWithGroupItem, RuleWithGroup, @@ -14,10 +11,11 @@ import { isCategoryRule, } from "@/utils/condition"; import { LogicalOperator } from "@prisma/client"; +import type { EmailProvider } from "@/utils/email/types"; export async function fetchExampleMessages( rule: RuleWithGroup, - gmail: gmail_v1.Gmail, + emailProvider: EmailProvider, ) { const isStatic = isStaticRule(rule); const isGroup = isGroupRule(rule); @@ -37,13 +35,14 @@ export async function fetchExampleMessages( ) return []; - if (isStatic) return fetchStaticExampleMessages(rule, gmail); + if (isStatic) return fetchStaticExampleMessages(rule, emailProvider); if (isGroup) { if (!rule.group) return []; + const { messages } = await fetchPaginatedMessages({ + emailProvider, groupItems: rule.group.items, - gmail, }); return messages; } @@ -53,33 +52,27 @@ export async function fetchExampleMessages( async function fetchStaticExampleMessages( rule: RuleWithGroup, - gmail: gmail_v1.Gmail, + emailProvider: EmailProvider, ): Promise { - let query = ""; + // Build structured query options instead of provider-specific query strings + const options: Parameters[0] = { + maxResults: 50, + }; + if (rule.from) { - query += `from:${rule.from} `; + options.froms = [rule.from]; } if (rule.to) { - query += `to:${rule.to} `; + options.tos = [rule.to]; } if (rule.subject) { - query += `subject:${rule.subject} `; + options.subjects = [rule.subject]; } - const response = await getMessages(gmail, { - query, - maxResults: 50, - }); - - const messages = await Promise.all( - (response.messages || []).map(async (message) => { - // TODO: Use email provider to get the message which will parse it internally - const m = await getMessage(message.id!, gmail); - const parsedMessage = parseMessage(m); - return parsedMessage; - }), - ); + const response = await emailProvider.getMessagesByFields(options); // search might include messages that don't match the rule, so we filter those out - return messages.filter((message) => matchesStaticRule(rule, message)); + return response.messages.filter((message) => + matchesStaticRule(rule, message), + ); } diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 7d79a6845d..4f36adfb9a 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -62,6 +62,7 @@ async function getActionsFromCategoryAction( categoryAction: CategoryAction, label: string, hasDigest: boolean, + provider: string, ): Promise { let actions: Prisma.ActionCreateManyRuleInput[] = [ { type: ActionType.LABEL, label }, @@ -83,7 +84,7 @@ async function getActionsFromCategoryAction( case "move_folder_delayed": { const emailProvider = await createEmailProvider({ emailAccountId, - provider: "microsoft", + provider, }); const folderId = await emailProvider.getOrCreateOutlookFolderIdByName( rule.name, @@ -563,6 +564,7 @@ export const createRulesOnboardingAction = actionClient categoryAction, label, hasDigest, + provider, ); return ( @@ -594,6 +596,7 @@ export const createRulesOnboardingAction = actionClient categoryAction, label, hasDigest, + provider, ); return prisma.rule diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 29e5fe01b3..a219d0e10d 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -10,16 +10,15 @@ import { filterNullProperties } from "@/utils"; const logger = createScopedLogger("ai-actions"); -type ActionFunction>> = - (options: { - client: EmailProvider; - email: EmailForAction; - args: T; - userEmail: string; - userId: string; - emailAccountId: string; - executedRule: ExecutedRule; - }) => Promise; +type ActionFunction>> = (options: { + client: EmailProvider; + email: EmailForAction; + args: T; + userEmail: string; + userId: string; + emailAccountId: string; + executedRule: ExecutedRule; +}) => Promise; export const runActionFunction = async (options: { client: EmailProvider; @@ -267,16 +266,22 @@ const track_thread: ActionFunction> = async ({ }); }; -const digest: ActionFunction = async ({ email, emailAccountId, args }) => { +const digest: ActionFunction<{ id?: string }> = async ({ + email, + emailAccountId, + args, +}) => { + if (!args.id) return; const actionId = args.id; await enqueueDigestItem({ email, emailAccountId, actionId }); }; -const move_folder: ActionFunction = async ({ +const move_folder: ActionFunction<{ folderId?: string | null }> = async ({ client, email, userEmail, args, }) => { + if (!args.folderId) return; await client.moveThreadToFolder(email.threadId, userEmail, args.folderId); }; diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 5d8d0fdebc..8bf7e8de52 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -550,6 +550,7 @@ export class GmailProvider implements EmailProvider { async getMessagesByFields(options: { froms?: string[]; + tos?: string[]; subjects?: string[]; before?: Date; after?: Date; @@ -571,6 +572,14 @@ export class GmailProvider implements EmailProvider { parts.push(`from:(${fromGroup})`); } + const tos = (options.tos || []) + .map((t) => extractEmailAddress(t) || t) + .filter((t) => !!t); + if (tos.length > 0) { + const toGroup = tos.map((t) => `"${t}"`).join(" OR "); + parts.push(`to:(${toGroup})`); + } + const subjects = (options.subjects || []).filter((s) => !!s); if (subjects.length > 0) { const subjectGroup = subjects.map((s) => `"${s}"`).join(" OR "); diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index c1e0564098..b7e9057ef0 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -365,7 +365,7 @@ export class OutlookProvider implements EmailProvider { content: string; contentType: string; }>; - }): Promise { + }) { return await sendEmailWithHtml(this.client, body); } @@ -637,6 +637,7 @@ export class OutlookProvider implements EmailProvider { async getMessagesByFields(options: { froms?: string[]; + tos?: string[]; subjects?: string[]; before?: Date; after?: Date; @@ -677,6 +678,19 @@ export class OutlookProvider implements EmailProvider { filters.push(`(${fromFilter})`); } + const tos = (options.tos || []) + .map((t) => extractEmailAddress(t) || t) + .filter((t) => !!t); + if (tos.length > 0) { + const toFilter = tos + .map( + (t) => + `toRecipients/any(r: r/emailAddress/address eq '${escapeODataString(t)}')`, + ) + .join(" or "); + filters.push(`(${toFilter})`); + } + const subjects = (options.subjects || []).filter((s) => !!s); if (subjects.length > 0) { // Use contains to match subject substrings; exact eq would be too strict diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 672f27c1ae..d6d58e04c5 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -47,6 +47,7 @@ export interface EmailProvider { }): Promise; getMessagesByFields(options: { froms?: string[]; + tos?: string[]; subjects?: string[]; before?: Date; after?: Date; @@ -119,7 +120,7 @@ export interface EmailProvider { content: string; contentType: string; }>; - }): Promise; + }): Promise; forwardEmail( email: ParsedMessage, args: { to: string; cc?: string; bcc?: string; content?: string }, diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index 04a2033b67..9c36d78d53 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -168,8 +168,8 @@ export async function queryBatchMessages( // Build folder filter for all cases const folderFilter = folderId - ? `parentFolderId eq '${folderId}'` - : `(parentFolderId eq '${inboxFolderId}' or parentFolderId eq '${archiveFolderId}')`; + ? `parentFolderId eq '${escapeODataString(folderId)}'` + : `(parentFolderId eq '${escapeODataString(inboxFolderId)}' or parentFolderId eq '${escapeODataString(archiveFolderId)}')`; if (hasSearchQuery) { // Search path - use $search parameter From df53b7afd8439d95ccb79d61d2032f278acae1bd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:07:49 +0300 Subject: [PATCH 15/25] fix up microsoft calls --- apps/web/app/api/google/labels/route.ts | 33 ++- .../web/app/api/google/threads/batch/route.ts | 47 ----- .../app/api/user/rules/[id]/example/route.ts | 18 +- apps/web/app/api/user/stats/day/route.ts | 87 ++++---- .../api/v1/group/[groupId]/emails/route.ts | 8 +- apps/web/app/api/v1/openapi/route.ts | 24 --- apps/web/utils/actions/report.ts | 7 +- .../web/utils/ai/report/build-user-persona.ts | 3 - apps/web/utils/ai/report/fetch.ts | 193 ++++-------------- apps/web/utils/api-auth.test.ts | 20 +- apps/web/utils/api-auth.ts | 14 +- apps/web/utils/email/google.ts | 37 ++++ apps/web/utils/email/microsoft.ts | 40 +++- apps/web/utils/email/types.ts | 6 + apps/web/utils/gmail/thread.ts | 26 +-- 15 files changed, 220 insertions(+), 343 deletions(-) delete mode 100644 apps/web/app/api/google/threads/batch/route.ts diff --git a/apps/web/app/api/google/labels/route.ts b/apps/web/app/api/google/labels/route.ts index bb62887468..1de9a3250f 100644 --- a/apps/web/app/api/google/labels/route.ts +++ b/apps/web/app/api/google/labels/route.ts @@ -1,8 +1,8 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { NextResponse } from "next/server"; -import { getLabels as getGmailLabels } from "@/utils/gmail/label"; -import { withEmailAccount } from "@/utils/middleware"; -import { getGmailClientForEmail } from "@/utils/account"; +import { withEmailProvider } from "@/utils/middleware"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { EmailProvider } from "@/utils/email/types"; export const dynamic = "force-dynamic"; export const maxDuration = 30; @@ -27,28 +27,25 @@ function isUserLabel(label: gmail_v1.Schema$Label): boolean { return label.type === "user"; } -async function getLabels(gmail: gmail_v1.Gmail): Promise { - const gmailLabels = await getGmailLabels(gmail); +async function getLabels( + emailProvider: EmailProvider, +): Promise { + const labels = await emailProvider.getLabels(); - const unifiedLabels: UnifiedLabel[] = (gmailLabels || []) - .filter((label) => isUserLabel(label)) - .map((label) => ({ - id: label.id || "", - name: label.name || "", - type: label.type || null, - color: label.color || undefined, - labelListVisibility: label.labelListVisibility || undefined, - messageListVisibility: label.messageListVisibility || undefined, - })); + const unifiedLabels: UnifiedLabel[] = (labels || []).filter((label) => + isUserLabel(label), + ); return { labels: unifiedLabels }; } -export const GET = withEmailAccount(async (request) => { +export const GET = withEmailProvider(async (request) => { const emailAccountId = request.auth.emailAccountId; + const provider = request.emailProvider.name; - const gmail = await getGmailClientForEmail({ emailAccountId }); - const labels = await getLabels(gmail); + const emailProvider = await createEmailProvider({ emailAccountId, provider }); + + const labels = await getLabels(emailProvider); return NextResponse.json(labels); }); diff --git a/apps/web/app/api/google/threads/batch/route.ts b/apps/web/app/api/google/threads/batch/route.ts deleted file mode 100644 index 415ec43044..0000000000 --- a/apps/web/app/api/google/threads/batch/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { withEmailAccount } from "@/utils/middleware"; -import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; -import { getGmailAndAccessTokenForEmail } from "@/utils/account"; - -const requestSchema = z.object({ - threadIds: z.array(z.string()), - includeDrafts: z.boolean().default(false), -}); - -export type ThreadsBatchResponse = Awaited< - ReturnType ->; - -export const GET = withEmailAccount(async (request) => { - const emailAccountId = request.auth.emailAccountId; - - const { searchParams } = new URL(request.url); - const { threadIds, includeDrafts } = requestSchema.parse({ - threadIds: searchParams.get("threadIds")?.split(",") || [], - includeDrafts: searchParams.get("includeDrafts") === "true", - }); - - if (threadIds.length === 0) { - return NextResponse.json({ threads: [] } satisfies ThreadsBatchResponse); - } - - const { accessToken } = await getGmailAndAccessTokenForEmail({ - emailAccountId, - }); - - if (!accessToken) { - return NextResponse.json( - { error: "Missing access token" }, - { status: 401 }, - ); - } - - const response = await getThreadsBatchAndParse( - threadIds, - accessToken, - includeDrafts, - ); - - return NextResponse.json(response); -}); diff --git a/apps/web/app/api/user/rules/[id]/example/route.ts b/apps/web/app/api/user/rules/[id]/example/route.ts index e627758ebd..5c3d03dbb1 100644 --- a/apps/web/app/api/user/rules/[id]/example/route.ts +++ b/apps/web/app/api/user/rules/[id]/example/route.ts @@ -1,18 +1,20 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; -import { withEmailAccount } from "@/utils/middleware"; +import { withEmailProvider } from "@/utils/middleware"; import { fetchExampleMessages } from "@/app/api/user/rules/[id]/example/controller"; import { SafeError } from "@/utils/error"; -import { getGmailClientForEmail } from "@/utils/account"; +import { createEmailProvider } from "@/utils/email/provider"; export type ExamplesResponse = Awaited>; async function getExamples({ ruleId, emailAccountId, + provider, }: { ruleId: string; emailAccountId: string; + provider: string; }) { const rule = await prisma.rule.findUnique({ where: { id: ruleId, emailAccountId }, @@ -21,20 +23,24 @@ async function getExamples({ if (!rule) throw new SafeError("Rule not found"); - const gmail = await getGmailClientForEmail({ emailAccountId }); + const emailProvider = await createEmailProvider({ + emailAccountId, + provider, + }); - const exampleMessages = await fetchExampleMessages(rule, gmail); + const exampleMessages = await fetchExampleMessages(rule, emailProvider); return exampleMessages; } -export const GET = withEmailAccount(async (request, { params }) => { +export const GET = withEmailProvider(async (request, { params }) => { const emailAccountId = request.auth.emailAccountId; + const provider = request.emailProvider.name; const { id } = await params; if (!id) return NextResponse.json({ error: "Missing rule id" }); - const result = await getExamples({ ruleId: id, emailAccountId }); + const result = await getExamples({ ruleId: id, emailAccountId, provider }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/stats/day/route.ts b/apps/web/app/api/user/stats/day/route.ts index 9b2f2b40aa..f9c22871cb 100644 --- a/apps/web/app/api/user/stats/day/route.ts +++ b/apps/web/app/api/user/stats/day/route.ts @@ -1,10 +1,8 @@ import { z } from "zod"; import { NextResponse } from "next/server"; -import type { gmail_v1 } from "@googleapis/gmail"; -import { withEmailAccount } from "@/utils/middleware"; -import { dateToSeconds } from "@/utils/date"; -import { getMessages } from "@/utils/gmail/message"; -import { getGmailClientForEmail } from "@/utils/account"; +import { withEmailProvider } from "@/utils/middleware"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { EmailProvider } from "@/utils/email/types"; const statsByDayQuery = z.object({ type: z.enum(["inbox", "sent", "archived"]), @@ -16,12 +14,39 @@ export type StatsByDayResponse = Awaited< const DAYS = 7; -async function getPastSevenDayStats( - options: { - emailAccountId: string; - gmail: gmail_v1.Gmail; - } & StatsByDayQuery, +async function getMessagesByType( + emailProvider: EmailProvider, + type: StatsByDayQuery["type"], + startOfDay: Date, + endOfDay: Date, ) { + if (type === "archived") { + // For archived messages, get all messages excluding inbox and sent + return emailProvider.getMessagesByFields({ + after: startOfDay, + before: endOfDay, + type: "all", + excludeSent: true, + excludeInbox: true, + maxResults: 500, + }); + } else { + // For inbox and sent, use the provider's built-in type filtering + return emailProvider.getMessagesByFields({ + after: startOfDay, + before: endOfDay, + type, + maxResults: 500, + }); + } +} + +async function getPastSevenDayStats({ + emailProvider, + type, +}: { + emailProvider: EmailProvider; +} & StatsByDayQuery) { const today = new Date(); const sevenDaysAgo = new Date( today.getFullYear(), @@ -42,14 +67,20 @@ async function getPastSevenDayStats( let count: number | undefined; if (typeof count !== "number") { - const query = getQuery(options.type, date); + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); - const messages = await getMessages(options.gmail, { - query, - maxResults: 500, - }); + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); - count = messages.messages?.length || 0; + const { messages } = await getMessagesByType( + emailProvider, + type, + startOfDay, + endOfDay, + ); + + count = messages.length; } return { @@ -62,35 +93,19 @@ async function getPastSevenDayStats( return lastSevenDaysCountsArray; } -function getQuery(type: StatsByDayQuery["type"], date: Date) { - const startOfDayInSeconds = dateToSeconds(date); - const endOfDayInSeconds = startOfDayInSeconds + 86_400; - - const dateRange = `after:${startOfDayInSeconds} before:${endOfDayInSeconds}`; - - switch (type) { - case "inbox": - return `in:inbox ${dateRange}`; - case "sent": - return `in:sent ${dateRange}`; - case "archived": - return `-in:inbox -in:sent ${dateRange}`; - } -} - -export const GET = withEmailAccount(async (request) => { +export const GET = withEmailProvider(async (request) => { const emailAccountId = request.auth.emailAccountId; + const provider = request.emailProvider.name; const { searchParams } = new URL(request.url); const type = searchParams.get("type"); const query = statsByDayQuery.parse({ type }); - const gmail = await getGmailClientForEmail({ emailAccountId }); + const emailProvider = await createEmailProvider({ emailAccountId, provider }); const result = await getPastSevenDayStats({ ...query, - gmail, - emailAccountId, + emailProvider, }); return NextResponse.json(result); diff --git a/apps/web/app/api/v1/group/[groupId]/emails/route.ts b/apps/web/app/api/v1/group/[groupId]/emails/route.ts index 0770ef1b64..664cef5091 100644 --- a/apps/web/app/api/v1/group/[groupId]/emails/route.ts +++ b/apps/web/app/api/v1/group/[groupId]/emails/route.ts @@ -5,12 +5,12 @@ import { type GroupEmailsResult, } from "@/app/api/v1/group/[groupId]/emails/validation"; import { withError } from "@/utils/middleware"; -import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth"; +import { validateApiKeyAndGetEmailProvider } from "@/utils/api-auth"; import { getEmailAccountId } from "@/app/api/v1/helpers"; export const GET = withError(async (request, { params }) => { - const { gmail, userId, accountId } = - await validateApiKeyAndGetGmailClient(request); + const { emailProvider, userId, accountId } = + await validateApiKeyAndGetEmailProvider(request); const { groupId } = await params; if (!groupId) @@ -44,9 +44,9 @@ export const GET = withError(async (request, { params }) => { } const { messages, nextPageToken } = await getGroupEmails({ + provider: emailProvider.name, groupId, emailAccountId, - gmail, from: from ? new Date(from) : undefined, to: to ? new Date(to) : undefined, pageToken, diff --git a/apps/web/app/api/v1/openapi/route.ts b/apps/web/app/api/v1/openapi/route.ts index 935f7ca9d8..4e13f424f5 100644 --- a/apps/web/app/api/v1/openapi/route.ts +++ b/apps/web/app/api/v1/openapi/route.ts @@ -9,10 +9,6 @@ import { groupEmailsQuerySchema, groupEmailsResponseSchema, } from "@/app/api/v1/group/[groupId]/emails/validation"; -import { - replyTrackerQuerySchema, - replyTrackerResponseSchema, -} from "@/app/api/v1/reply-tracker/validation"; import { API_KEY_HEADER } from "@/utils/api-auth"; extendZodWithOpenApi(z); @@ -52,26 +48,6 @@ registry.registerPath({ }, }); -registry.registerPath({ - method: "get", - path: "/reply-tracker", - description: "Get emails that need a reply or follow up", - security: [{ ApiKeyAuth: [] }], - request: { - query: replyTrackerQuerySchema, - }, - responses: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: replyTrackerResponseSchema, - }, - }, - }, - }, -}); - export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const customHost = searchParams.get("host"); diff --git a/apps/web/utils/actions/report.ts b/apps/web/utils/actions/report.ts index ba39eaf025..ea1f399605 100644 --- a/apps/web/utils/actions/report.ts +++ b/apps/web/utils/actions/report.ts @@ -2,10 +2,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { z } from "zod"; -import { - fetchEmailsForReport, - fetchGmailTemplates, -} from "@/utils/ai/report/fetch"; +import { fetchEmailsForReport } 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"; @@ -72,7 +69,6 @@ async function getEmailReportData({ const gmailLabels = await fetchGmailLabels(gmail, logger); const gmailSignature = await fetchGmailSignature(gmail, logger); - const gmailTemplates = await fetchGmailTemplates(gmail); const [ executiveSummary, @@ -94,7 +90,6 @@ async function getEmailReportData({ emailAccount, sentSummaries, gmailSignature, - gmailTemplates, ).catch((error) => { logger.error("Error generating user persona", { error }); }), diff --git a/apps/web/utils/ai/report/build-user-persona.ts b/apps/web/utils/ai/report/build-user-persona.ts index 010603f163..7fff7c5bb2 100644 --- a/apps/web/utils/ai/report/build-user-persona.ts +++ b/apps/web/utils/ai/report/build-user-persona.ts @@ -2,11 +2,8 @@ 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"), diff --git a/apps/web/utils/ai/report/fetch.ts b/apps/web/utils/ai/report/fetch.ts index 2712e9fc5e..8e83ab5948 100644 --- a/apps/web/utils/ai/report/fetch.ts +++ b/apps/web/utils/ai/report/fetch.ts @@ -1,141 +1,12 @@ -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"; +import { createEmailProvider } from "@/utils/email/provider"; +import type { EmailProvider } from "@/utils/email/types"; 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, }: { @@ -145,13 +16,14 @@ export async function fetchEmailsForReport({ emailAccountId: emailAccount.id, }); - const gmail = await getGmailClientForEmail({ + const emailProvider = await createEmailProvider({ emailAccountId: emailAccount.id, + provider: emailAccount.account.provider, }); - const receivedEmails = await fetchReceivedEmails(gmail, 200); + const receivedEmails = await fetchReceivedEmails(emailProvider, 200); await sleep(3000); - const sentEmails = await fetchSentEmails(gmail, 50); + const sentEmails = await fetchSentEmails(emailProvider, 50); logger.info("fetchEmailsForReport: preparing return result", { receivedCount: receivedEmails.length, @@ -167,30 +39,44 @@ export async function fetchEmailsForReport({ } async function fetchReceivedEmails( - gmail: gmail_v1.Gmail, + emailProvider: EmailProvider, targetCount: number, ): Promise { const emails: ParsedMessage[] = []; + + // Fetch from different sources in priority order const sources = [ - { name: "inbox", query: "in:inbox" }, - { name: "archived", query: "-in:inbox -in:sent -in:trash" }, - { name: "trash", query: "in:trash" }, + { name: "inbox", type: "inbox" as const }, + { + name: "archived", + type: "all" as const, + excludeInbox: true, + excludeSent: true, + }, ]; for (const source of sources) { if (emails.length >= targetCount) break; try { - const sourceEmails = await fetchEmailsByQuery( - gmail, - source.query, - targetCount - emails.length, - ); - emails.push(...sourceEmails); + const response = await emailProvider.getMessagesByFields({ + type: source.type, + excludeInbox: source.excludeInbox, + excludeSent: source.excludeSent, + maxResults: targetCount - emails.length, + }); + + emails.push(...response.messages); + + logger.info(`Fetched emails from ${source.name}`, { + count: response.messages.length, + totalSoFar: emails.length, + targetCount, + }); } catch (error) { logger.error(`Error fetching emails from ${source.name}`, { error, - query: source.query, + sourceConfig: source, }); } } @@ -199,24 +85,27 @@ async function fetchReceivedEmails( } async function fetchSentEmails( - gmail: gmail_v1.Gmail, + emailProvider: EmailProvider, targetCount: number, ): Promise { try { - const emails = await fetchEmailsByQuery(gmail, "from:me", targetCount); + const response = await emailProvider.getMessagesByFields({ + type: "sent", + maxResults: targetCount, + }); - return emails; + return response.messages; } catch (error) { logger.error("Error fetching sent emails", { error }); return []; } } -export async function fetchGmailTemplates( - gmail: gmail_v1.Gmail, +export async function fetchEmailTemplates( + emailProvider: EmailProvider, ): Promise { try { - const drafts = await fetchEmailsByQuery(gmail, "in:draft", 50); + const drafts = await emailProvider.getDrafts({ maxResults: 50 }); const templates: string[] = []; @@ -236,7 +125,7 @@ export async function fetchGmailTemplates( return templates; } catch (error) { - logger.warn("Failed to fetch Gmail templates:", { + logger.warn("Failed to fetch email templates:", { error: error instanceof Error ? error.message : String(error), }); return []; diff --git a/apps/web/utils/api-auth.test.ts b/apps/web/utils/api-auth.test.ts index 84595e6c27..6649f30dc4 100644 --- a/apps/web/utils/api-auth.test.ts +++ b/apps/web/utils/api-auth.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { validateApiKey, getUserFromApiKey, - validateApiKeyAndGetGmailClient, + validateApiKeyAndGetEmailProvider, } from "./api-auth"; import prisma from "@/utils/__mocks__/prisma"; import { hashApiKey } from "@/utils/api-key"; @@ -119,10 +119,10 @@ describe("api-auth", () => { }, } as unknown as NextRequest; - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( SafeError, ); - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( "Missing API key", ); }); @@ -145,10 +145,10 @@ describe("api-auth", () => { isActive: true, } as MockApiKeyResult); - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( SafeError, ); - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( "Missing account", ); }); @@ -176,10 +176,10 @@ describe("api-auth", () => { isActive: true, } as MockApiKeyResult); - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( SafeError, ); - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( "Missing access token", ); }); @@ -214,10 +214,10 @@ describe("api-auth", () => { new Error("Error refreshing Gmail access token"), ); - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( Error, ); - await expect(validateApiKeyAndGetGmailClient(request)).rejects.toThrow( + await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( "Error refreshing Gmail access token", ); }); @@ -255,7 +255,7 @@ describe("api-auth", () => { mockGmailClient, ); - const result = await validateApiKeyAndGetGmailClient(request); + const result = await validateApiKeyAndGetEmailProvider(request); expect(result).toEqual({ accessToken: "access-token", gmail: mockGmailClient, diff --git a/apps/web/utils/api-auth.ts b/apps/web/utils/api-auth.ts index f59446476f..4bc8da61cf 100644 --- a/apps/web/utils/api-auth.ts +++ b/apps/web/utils/api-auth.ts @@ -1,8 +1,8 @@ import type { NextRequest } from "next/server"; import prisma from "@/utils/prisma"; import { hashApiKey } from "@/utils/api-key"; -import { getGmailClientWithRefresh } from "@/utils/gmail/client"; import { SafeError } from "@/utils/error"; +import { createEmailProvider } from "@/utils/email/provider"; export const API_KEY_HEADER = "API-Key"; @@ -44,6 +44,7 @@ export async function getUserFromApiKey(secretKey: string) { access_token: true, refresh_token: true, expires_at: true, + provider: true, }, take: 1, }, @@ -62,7 +63,7 @@ export async function getUserFromApiKey(secretKey: string) { * @returns The Gmail client and user ID * @throws SafeError if authentication fails */ -export async function validateApiKeyAndGetGmailClient(request: NextRequest) { +export async function validateApiKeyAndGetEmailProvider(request: NextRequest) { const { user } = await validateApiKey(request); // TODO: support API For multiple accounts @@ -73,16 +74,13 @@ export async function validateApiKeyAndGetGmailClient(request: NextRequest) { if (!account.access_token || !account.refresh_token || !account.expires_at) throw new SafeError("Missing access token", 401); - const gmail = await getGmailClientWithRefresh({ - accessToken: account.access_token, - refreshToken: account.refresh_token, - expiresAt: account.expires_at?.getTime() || null, + const emailProvider = await createEmailProvider({ emailAccountId: account.id, + provider: account.provider, }); return { - gmail, - accessToken: account.access_token, + emailProvider, userId: user.id, accountId: account.id, }; diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 8bf7e8de52..a021c8b368 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -250,6 +250,30 @@ export class GmailProvider implements EmailProvider { } } + // async unarchiveMessage(messageId: string): Promise { + // await labelMessage({ + // gmail: this.client, + // messageId, + // addLabelIds: [GmailLabel.INBOX], + // }); + // } + + // async markMessageAsRead(messageId: string): Promise { + // await labelMessage({ + // gmail: this.client, + // messageId, + // removeLabelIds: [GmailLabel.UNREAD], + // }); + // } + + // async markMessageAsUnread(messageId: string): Promise { + // await labelMessage({ + // gmail: this.client, + // messageId, + // addLabelIds: [GmailLabel.UNREAD], + // }); + // } + async trashThread( threadId: string, ownerEmail: string, @@ -556,6 +580,7 @@ export class GmailProvider implements EmailProvider { after?: Date; type?: "inbox" | "sent" | "all"; excludeSent?: boolean; + excludeInbox?: boolean; maxResults?: number; pageToken?: string; }): Promise<{ @@ -595,6 +620,9 @@ export class GmailProvider implements EmailProvider { if (options.excludeSent) { parts.push(`-in:${GmailLabel.SENT}`); } + if (options.excludeInbox) { + parts.push(`-in:${GmailLabel.INBOX}`); + } const query = parts.join(" ") || undefined; @@ -607,6 +635,15 @@ export class GmailProvider implements EmailProvider { }); } + async getDrafts(options?: { maxResults?: number }): Promise { + const response = await this.getMessagesWithPagination({ + query: "in:draft", + maxResults: options?.maxResults || 50, + }); + + return response.messages; + } + async getMessagesBatch(messageIds: string[]): Promise { return getMessagesBatch({ messageIds, diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index b7e9057ef0..9bd60f6d4d 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -643,6 +643,7 @@ export class OutlookProvider implements EmailProvider { after?: Date; type?: "inbox" | "sent" | "all"; excludeSent?: boolean; + excludeInbox?: boolean; maxResults?: number; pageToken?: string; }): Promise<{ @@ -668,6 +669,10 @@ export class OutlookProvider implements EmailProvider { filters.push("parentFolderId ne 'sentitems'"); } + if (options.excludeInbox) { + filters.push("parentFolderId ne 'inbox'"); + } + const froms = (options.froms || []) .map((f) => extractEmailAddress(f) || f) .filter((f) => !!f); @@ -711,6 +716,14 @@ export class OutlookProvider implements EmailProvider { }); } + async getDrafts(options?: { maxResults?: number }): Promise { + const response = await this.getMessagesWithPagination({ + query: "isDraft eq true", + maxResults: options?.maxResults || 50, + }); + return response.messages; + } + async getMessagesBatch(messageIds: string[]): Promise { // For Outlook, we need to fetch messages individually since there's no batch endpoint const messagePromises = messageIds.map((messageId) => @@ -856,12 +869,12 @@ export class OutlookProvider implements EmailProvider { // Handle structured date options if (after) { const afterISO = after.toISOString(); - filters.push(`receivedDateTime gt ${afterISO}`); + filters.push(`receivedDateTime gt '${afterISO}'`); } if (before) { const beforeISO = before.toISOString(); - filters.push(`receivedDateTime lt ${beforeISO}`); + filters.push(`receivedDateTime lt '${beforeISO}'`); } if (isUnread) { @@ -878,7 +891,6 @@ export class OutlookProvider implements EmailProvider { ) .top(options.maxResults || 50); - // Add filter if present if (filter) { request = request.filter(filter); } @@ -888,7 +900,6 @@ export class OutlookProvider implements EmailProvider { request = request.orderby("receivedDateTime DESC"); } - // Handle pagination if (options.pageToken) { request = request.skipToken(options.pageToken); } @@ -1167,6 +1178,27 @@ export class OutlookProvider implements EmailProvider { } } + // async unarchiveMessage(messageId: string): Promise { + // await this.client + // .getClient() + // .api(`/me/messages/${messageId}/move`) + // .post({ destinationId: "inbox" }); + // } + + // async markMessageAsRead(messageId: string): Promise { + // await this.client + // .getClient() + // .api(`/me/messages/${messageId}`) + // .patch({ isRead: true }); + // } + + // async markMessageAsUnread(messageId: string): Promise { + // await this.client + // .getClient() + // .api(`/me/messages/${messageId}`) + // .patch({ isRead: false }); + // } + async getOrCreateOutlookFolderIdByName(folderName: string): Promise { return await getOrCreateOutlookFolderIdByName(this.client, folderName); } diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index d6d58e04c5..d8148fb6a3 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -53,6 +53,7 @@ export interface EmailProvider { after?: Date; type?: "inbox" | "sent" | "all"; excludeSent?: boolean; + excludeInbox?: boolean; maxResults?: number; pageToken?: string; }): Promise<{ @@ -65,6 +66,7 @@ export interface EmailProvider { excludeFromEmails?: string[]; maxResults?: number; }): Promise; + getDrafts(options?: { maxResults?: number }): Promise; getThreadMessages(threadId: string): Promise; getThreadMessagesInInbox(threadId: string): Promise; getPreviousConversationMessages( @@ -77,12 +79,16 @@ export interface EmailProvider { labelId?: string, ): Promise; archiveMessage(messageId: string): Promise; + // unarchiveMessage(messageId: string): Promise; + // markMessageAsRead(messageId: string): Promise; + // markMessageAsUnread(messageId: string): Promise; trashThread( threadId: string, ownerEmail: string, actionSource: "user" | "automation", ): Promise; labelMessage(messageId: string, labelName: string): Promise; + removeThreadLabel(threadId: string, labelId: string): Promise; getNeedsReplyLabel(): Promise; getAwaitingReplyLabel(): Promise; diff --git a/apps/web/utils/gmail/thread.ts b/apps/web/utils/gmail/thread.ts index f83a739e83..f2b4ea78f3 100644 --- a/apps/web/utils/gmail/thread.ts +++ b/apps/web/utils/gmail/thread.ts @@ -5,7 +5,7 @@ import { type ThreadWithPayloadMessages, type MessageWithPayload, } from "@/utils/types"; -import { parseMessage, parseMessages } from "@/utils/gmail/message"; +import { parseMessage } from "@/utils/gmail/message"; import { GmailLabel } from "@/utils/gmail/label"; export async function getThread( @@ -85,30 +85,6 @@ export async function getThreadsBatch( return batch; } -export async function getThreadsBatchAndParse( - threadIds: string[], - accessToken: string, - includeDrafts: boolean, -) { - const threads = await getThreadsBatch(threadIds, accessToken); - - const threadsWithMessages = threads.map((thread) => { - const id = thread.id; - if (!id) return; - - const messages = parseMessages(thread, { - withoutIgnoredSenders: true, - withoutDrafts: !includeDrafts, - }); - - return { id, messages }; - }); - - return { - threads: threadsWithMessages.filter(isDefined), - }; -} - async function getThreadsFromSender( gmail: gmail_v1.Gmail, sender: string, From bc2fffc37f83ce740e6a81d9f3dad5edbe15790e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:08:33 +0300 Subject: [PATCH 16/25] remove mcp server and reply tracker api endpoint --- apps/mcp-server/.env.example | 2 - apps/mcp-server/.gitignore | 1 - apps/mcp-server/README.md | 20 --- apps/mcp-server/package.json | 24 --- apps/mcp-server/src/index.ts | 137 ------------------ apps/mcp-server/tsconfig.json | 15 -- apps/web/app/api/v1/reply-tracker/route.ts | 99 ------------- .../app/api/v1/reply-tracker/validation.ts | 22 --- biome.json | 1 - docker/Dockerfile.prod | 1 - 10 files changed, 322 deletions(-) delete mode 100644 apps/mcp-server/.env.example delete mode 100644 apps/mcp-server/.gitignore delete mode 100644 apps/mcp-server/README.md delete mode 100644 apps/mcp-server/package.json delete mode 100644 apps/mcp-server/src/index.ts delete mode 100644 apps/mcp-server/tsconfig.json delete mode 100644 apps/web/app/api/v1/reply-tracker/route.ts delete mode 100644 apps/web/app/api/v1/reply-tracker/validation.ts diff --git a/apps/mcp-server/.env.example b/apps/mcp-server/.env.example deleted file mode 100644 index dbe8279510..0000000000 --- a/apps/mcp-server/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -API_BASE=http://localhost:3000/api/v1 -API_KEY=your_api_key_here diff --git a/apps/mcp-server/.gitignore b/apps/mcp-server/.gitignore deleted file mode 100644 index c795b054e5..0000000000 --- a/apps/mcp-server/.gitignore +++ /dev/null @@ -1 +0,0 @@ -build \ No newline at end of file diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md deleted file mode 100644 index 3449d360f9..0000000000 --- a/apps/mcp-server/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Inbox Zero MCP Server - -An MCP server to manage your inbox efficiently. Use it within Cursor, Windsurf, or Claude desktop to interact with your Inbox Zero personal assistant. - -## Run it locally - -From this directory: - -``` -pnpm run build -pnpm start -``` - -Then use the MCP at path `apps/mcp-server/build/index.js` in Cursor or Claude Desktop. Note, use the full path. - -ATM, you should replace the empty string with your API key (PRs welcome to improve this). You can get your API key from the `/settings` page in the web app: - -```js -const API_KEY = process.env.API_KEY || ""; -``` diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json deleted file mode 100644 index 2647c103cd..0000000000 --- a/apps/mcp-server/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@inbox-zero/mcp-server", - "version": "0.0.1", - "private": true, - "type": "module", - "bin": { - "inbox-zero-ai": "./build/index.js" - }, - "scripts": { - "start": "node build/index.js", - "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"" - }, - "files": [ - "build" - ], - "dependencies": { - "@modelcontextprotocol/sdk": "1.17.4", - "zod": "3.25.46" - }, - "devDependencies": { - "@types/node": "24.3.0", - "typescript": "5.9.2" - } -} diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts deleted file mode 100644 index 97c5f3dfe9..0000000000 --- a/apps/mcp-server/src/index.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; - -// Configuration -const API_BASE = process.env.API_BASE || "http://localhost:3000/api/v1"; -const API_KEY = process.env.API_KEY || ""; - -if (!API_KEY) { - console.error( - "Error: API_KEY environment variable is not set. API requests will fail.", - ); - process.exit(1); -} - -// Create server instance -const server = new McpServer({ - name: "inbox-zero-ai", - version: "0.0.1", -}); - -async function makeIZRequest(url: string): Promise { - const headers = { - "API-Key": API_KEY, - "Content-Type": "application/json", - }; - - console.log(`Making request to ${url}`); - - try { - const response = await fetch(url, { headers }); - - if (!response.ok) { - const errorText = await response.text(); - console.error( - `HTTP error! status: ${response.status}, response: ${errorText}`, - ); - throw new Error( - `HTTP error! status: ${response.status}, response: ${errorText}`, - ); - } - - return (await response.json()) as T; - } catch (error) { - console.error("Error making Inbox Zero API request:", error); - return null; - } -} - -// Define types based on the validation schemas -type ReplyTrackerResponse = { - emails: Array<{ - threadId: string; - subject: string; - from: string; - date: string; - snippet: string; - }>; - count: number; -}; - -// Helper functions for formatting email data -function formatReplyTrackerEmail(email: ReplyTrackerResponse["emails"][0]) { - return `- From: ${email.from}\n Subject: ${email.subject}\n Date: ${email.date}\n Snippet: ${email.snippet}`; -} - -// Helper function to create a formatted response -function createTextResponse(text: string) { - return { - content: [ - { - type: "text" as const, - text, - }, - ], - }; -} - -// Register tools -server.tool( - "get-emails-needing-reply", - "Get emails needing reply", - { - olderThan: z - .enum(["3d", "1w", "2w", "1m"]) - .describe("Time range to look back"), - }, - async ({ olderThan }) => { - const url = `${API_BASE}/reply-tracker?type=needs-reply&timeRange=${olderThan}`; - const data = await makeIZRequest(url); - - if (!data) { - return createTextResponse("Failed to fetch emails needing reply."); - } - - const emailList = data.emails.map(formatReplyTrackerEmail).join("\n\n"); - return createTextResponse( - `Found ${data.count} emails needing reply:\n\n${emailList}`, - ); - }, -); - -server.tool( - "get-emails-needing-follow-up", - "Get emails needing follow-up", - { - olderThan: z - .enum(["3d", "1w", "2w", "1m"]) - .describe("Time range to look back"), - }, - async ({ olderThan }) => { - const url = `${API_BASE}/reply-tracker?type=needs-follow-up&timeRange=${olderThan}`; - const data = await makeIZRequest(url); - - if (!data) { - return createTextResponse( - `Failed to fetch emails needing follow-up older than ${olderThan} days.`, - ); - } - - const emailList = data.emails.map(formatReplyTrackerEmail).join("\n\n"); - return createTextResponse( - `Found ${data.count} emails needing follow-up:\n\n${emailList}`, - ); - }, -); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("Inbox Zero MCP Server running on stdio"); -} - -main().catch((error) => { - console.error("Fatal error in main():", error); - process.exit(1); -}); diff --git a/apps/mcp-server/tsconfig.json b/apps/mcp-server/tsconfig.json deleted file mode 100644 index a14bee0705..0000000000 --- a/apps/mcp-server/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules"] -} diff --git a/apps/web/app/api/v1/reply-tracker/route.ts b/apps/web/app/api/v1/reply-tracker/route.ts deleted file mode 100644 index f7272932aa..0000000000 --- a/apps/web/app/api/v1/reply-tracker/route.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { NextResponse } from "next/server"; -import { withError } from "@/utils/middleware"; -import { createScopedLogger } from "@/utils/logger"; -import { - replyTrackerQuerySchema, - type ReplyTrackerResponse, -} from "./validation"; -import { validateApiKeyAndGetGmailClient } from "@/utils/api-auth"; -import { ThreadTrackerType } from "@prisma/client"; -import { getPaginatedThreadTrackers } from "@/app/(app)/[emailAccountId]/reply-zero/fetch-trackers"; -import { getThreadsBatchAndParse } from "@/utils/gmail/thread"; -import { isDefined } from "@/utils/types"; -import { getEmailAccountId } from "@/app/api/v1/helpers"; - -const logger = createScopedLogger("api/v1/reply-tracker"); - -export const GET = withError(async (request) => { - const { accessToken, userId, accountId } = - await validateApiKeyAndGetGmailClient(request); - - const { searchParams } = new URL(request.url); - const queryResult = replyTrackerQuerySchema.safeParse( - Object.fromEntries(searchParams), - ); - - if (!queryResult.success) { - return NextResponse.json( - { error: "Invalid query parameters" }, - { status: 400 }, - ); - } - - const emailAccountId = await getEmailAccountId({ - email: queryResult.data.email, - accountId, - userId, - }); - - if (!emailAccountId) { - return NextResponse.json( - { error: "Email account not found" }, - { status: 400 }, - ); - } - - try { - function getType(type: "needs-reply" | "needs-follow-up") { - if (type === "needs-reply") return ThreadTrackerType.NEEDS_REPLY; - if (type === "needs-follow-up") return ThreadTrackerType.AWAITING; - throw new Error("Invalid type"); - } - - const { trackers, count } = await getPaginatedThreadTrackers({ - emailAccountId, - type: getType(queryResult.data.type), - page: queryResult.data.page, - timeRange: queryResult.data.timeRange, - }); - - const threads = await getThreadsBatchAndParse( - trackers.map((tracker) => tracker.threadId), - accessToken, - false, - ); - - const response: ReplyTrackerResponse = { - emails: threads.threads - .map((thread) => { - const lastMessage = thread.messages[thread.messages.length - 1]; - if (!lastMessage) return null; - return { - threadId: thread.id, - subject: lastMessage.headers.subject, - from: lastMessage.headers.from, - date: lastMessage.headers.date, - snippet: lastMessage.snippet, - }; - }) - .filter(isDefined), - count, - }; - - logger.info("Retrieved emails needing reply", { - userId, - count: response.emails.length, - }); - - return NextResponse.json(response); - } catch (error) { - logger.error("Error retrieving emails needing reply", { - userId, - error, - }); - return NextResponse.json( - { error: "Failed to retrieve emails" }, - { status: 500 }, - ); - } -}); diff --git a/apps/web/app/api/v1/reply-tracker/validation.ts b/apps/web/app/api/v1/reply-tracker/validation.ts deleted file mode 100644 index 357c7246b1..0000000000 --- a/apps/web/app/api/v1/reply-tracker/validation.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from "zod"; - -export const replyTrackerQuerySchema = z.object({ - type: z.enum(["needs-reply", "needs-follow-up"]), - page: z.number().optional().default(1), - timeRange: z.enum(["all", "3d", "1w", "2w", "1m"]).optional().default("all"), - email: z.string().optional(), -}); - -export const replyTrackerResponseSchema = z.object({ - emails: z.array( - z.object({ - threadId: z.string().describe("Thread ID"), - subject: z.string().describe("Subject"), - from: z.string().describe("From"), - date: z.string().describe("Date"), - snippet: z.string().describe("Preview snippet of the email content"), - }), - ), - count: z.number().describe("Total number of emails needing reply"), -}); -export type ReplyTrackerResponse = z.infer; diff --git a/biome.json b/biome.json index ab6d71401d..34ff57df25 100644 --- a/biome.json +++ b/biome.json @@ -120,7 +120,6 @@ "**/*.test.*", "**/*.spec.*", "apps/unsubscriber/**", - "apps/mcp-server/**", "packages/**", "**/*.tsx", "**/scripts/**" diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index c9b6bc1b95..59ae4c83d4 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -13,7 +13,6 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./ # Create directory structure and copy package.json files COPY apps/web/package.json apps/web/package.json COPY apps/unsubscriber/package.json apps/unsubscriber/package.json -COPY apps/mcp-server/package.json apps/mcp-server/package.json COPY packages/loops/package.json packages/loops/package.json COPY packages/resend/package.json packages/resend/package.json COPY packages/tinybird/package.json packages/tinybird/package.json From 733e7582968c50ba8ec484198eee20fdb96e094d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:08:56 +0300 Subject: [PATCH 17/25] pnpm lock update --- pnpm-lock.yaml | 293 +------------------------------------------------ 1 file changed, 2 insertions(+), 291 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa782759a4..b7a83ffc06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,22 +42,6 @@ importers: specifier: 5.3.3 version: 5.3.3(@inquirer/prompts@7.8.4(@types/node@24.3.0))(@types/debug@4.1.12)(@types/node@24.3.0)(jsdom@26.1.0)(typescript@5.9.2) - apps/mcp-server: - dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.17.4 - version: 1.17.4 - zod: - specifier: 3.25.46 - version: 3.25.46 - devDependencies: - '@types/node': - specifier: 24.3.0 - version: 24.3.0 - typescript: - specifier: 5.9.2 - version: 5.9.2 - apps/unsubscriber: dependencies: '@ai-sdk/amazon-bedrock': @@ -2351,10 +2335,6 @@ packages: '@microsoft/microsoft-graph-types@2.40.0': resolution: {integrity: sha512-1fcPVrB/NkbNcGNfCy+Cgnvwxt6/sbIEEFgZHFBJ670zYLegENYJF8qMo7x3LqBjWX2/Eneq5BVVRCLTmlJN+g==} - '@modelcontextprotocol/sdk@1.17.4': - resolution: {integrity: sha512-zq24hfuAmmlNZvik0FLI58uE5sriN0WWsQzIlYnzSuKDAHFqJtBFrl/LfB1NLgJT5Y7dEBzaX4yAKqOPrcetaw==} - engines: {node: '>=18'} - '@mux/mux-data-google-ima@0.2.8': resolution: {integrity: sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==} @@ -5079,10 +5059,6 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -5165,9 +5141,6 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -5405,10 +5378,6 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.2.0: - resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} - engines: {node: '>=18'} - boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -5824,10 +5793,6 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} - content-disposition@1.0.0: - resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} - engines: {node: '>= 0.6'} - content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -5838,10 +5803,6 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} @@ -6612,10 +6573,6 @@ packages: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} engines: {node: '>=12.0.0'} - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - eventsource@4.0.0: resolution: {integrity: sha512-fvIkb9qZzdMxgZrEQDyll+9oJsyaVvY92I2Re+qK0qEJ+w5s0X3dtz+M0VAPOjP1gtU3iqWyjQ0G3nvd5CLZ2g==} engines: {node: '>=20.0.0'} @@ -6635,20 +6592,10 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} - express-rate-limit@7.5.1: - resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - express@4.21.2: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} - express@5.1.0: - resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} - engines: {node: '>= 18'} - exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -6679,9 +6626,6 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-json-stringify@6.0.1: resolution: {integrity: sha512-s7SJE83QKBZwg54dIbD5rCtzOBVD43V1ReWXXYqBgwCwHLYAAT0RQc/FmrQglXqWPpz6omtryJQOau5jI4Nrvg==} @@ -6764,10 +6708,6 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} - finalhandler@2.1.0: - resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} - engines: {node: '>= 0.8'} - find-cache-dir@2.1.0: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} engines: {node: '>=6'} @@ -6863,10 +6803,6 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} @@ -7463,9 +7399,6 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -7657,9 +7590,6 @@ packages: json-schema-ref-resolver@2.0.1: resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -8058,10 +7988,6 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - mendoza@3.0.8: resolution: {integrity: sha512-iwxgEpSOx9BDLJMD0JAzNicqo9xdrvzt6w/aVwBKMndlA6z/DH41+o60H2uHB0vCR1Xr37UOgu9xFWJHvYsuKw==} engines: {node: '>=14.18'} @@ -8073,10 +7999,6 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -8368,10 +8290,6 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -8830,10 +8748,6 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.2.0: - resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} - engines: {node: '>=16'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -8921,10 +8835,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - pkce-challenge@5.0.0: - resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} - engines: {node: '>=16.20.0'} - pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -9272,10 +9182,6 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} - raw-body@3.0.0: - resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} - engines: {node: '>= 0.8'} - rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -9719,10 +9625,6 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - rrweb-cssom@0.6.0: resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} @@ -9859,10 +9761,6 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} - send@1.2.0: - resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} - engines: {node: '>= 18'} - sentence-case@2.1.1: resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==} @@ -9873,10 +9771,6 @@ packages: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} - serve-static@2.2.0: - resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} - engines: {node: '>= 18'} - server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -10103,10 +9997,6 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -10618,10 +10508,6 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -10762,9 +10648,6 @@ packages: upper-case@1.1.3: resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} @@ -13134,23 +13017,6 @@ snapshots: '@microsoft/microsoft-graph-types@2.40.0': {} - '@modelcontextprotocol/sdk@1.17.4': - dependencies: - ajv: 6.12.6 - content-type: 1.0.5 - cors: 2.8.5 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.1.0 - express-rate-limit: 7.5.1(express@5.1.0) - pkce-challenge: 5.0.0 - raw-body: 3.0.0 - zod: 3.25.46 - zod-to-json-schema: 3.24.6(zod@3.25.46) - transitivePeerDependencies: - - supports-color - '@mux/mux-data-google-ima@0.2.8': dependencies: mux-embed: 5.9.0 @@ -16230,7 +16096,7 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.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/mocker@3.2.4(vite@7.1.4(@types/node@24.3.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 @@ -16432,11 +16298,6 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - accepts@2.0.0: - dependencies: - mime-types: 3.0.1 - negotiator: 1.0.0 - acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -16507,13 +16368,6 @@ snapshots: ajv: 8.17.1 fast-deep-equal: 3.1.3 - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -16769,20 +16623,6 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.2.0: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.1(supports-color@8.1.1) - http-errors: 2.0.0 - iconv-lite: 0.6.3 - on-finished: 2.4.1 - qs: 6.14.0 - raw-body: 3.0.0 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - boolbase@1.0.0: {} brace-expansion@1.1.12: @@ -17251,18 +17091,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 - content-disposition@1.0.0: - dependencies: - safe-buffer: 5.2.1 - content-type@1.0.5: {} convert-source-map@2.0.0: {} cookie-signature@1.0.6: {} - cookie-signature@1.2.2: {} - cookie@0.7.1: {} cookie@0.7.2: {} @@ -18049,10 +17883,6 @@ snapshots: eventsource@2.0.2: {} - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - eventsource@4.0.0: dependencies: eventsource-parser: 3.0.6 @@ -18085,10 +17915,6 @@ snapshots: expect-type@1.2.2: {} - express-rate-limit@7.5.1(express@5.1.0): - dependencies: - express: 5.1.0 - express@4.21.2: dependencies: accepts: 1.3.8 @@ -18125,38 +17951,6 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.1.0: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.0 - content-disposition: 1.0.0 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.1(supports-color@8.1.1) - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.0 - fresh: 2.0.0 - http-errors: 2.0.0 - merge-descriptors: 2.0.0 - mime-types: 3.0.1 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.14.0 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.0 - serve-static: 2.2.0 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - exsolve@1.0.7: {} extend@3.0.2: {} @@ -18185,8 +17979,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} - fast-json-stringify@6.0.1: dependencies: '@fastify/merge-json-schemas': 0.2.1 @@ -18285,17 +18077,6 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.0: - dependencies: - debug: 4.4.1(supports-color@8.1.1) - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - find-cache-dir@2.1.0: dependencies: commondir: 1.0.1 @@ -18389,8 +18170,6 @@ snapshots: fresh@0.5.2: {} - fresh@2.0.0: {} - from2@2.3.0: dependencies: inherits: 2.0.4 @@ -19136,8 +18915,6 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-promise@4.0.0: {} - is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -19332,8 +19109,6 @@ snapshots: dependencies: dequal: 2.0.3 - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} json-schema@0.4.0: {} @@ -19832,8 +19607,6 @@ snapshots: media-typer@0.3.0: {} - media-typer@1.1.0: {} - mendoza@3.0.8: {} meow@9.0.0: @@ -19853,8 +19626,6 @@ snapshots: merge-descriptors@1.0.3: {} - merge-descriptors@2.0.0: {} - merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -20299,8 +20070,6 @@ snapshots: negotiator@0.6.3: {} - negotiator@1.0.0: {} - neo-async@2.6.2: {} netmask@2.0.2: {} @@ -20777,8 +20546,6 @@ snapshots: path-to-regexp@6.3.0: {} - path-to-regexp@8.2.0: {} - path-type@4.0.0: {} pathe@2.0.3: {} @@ -20853,8 +20620,6 @@ snapshots: pirates@4.0.7: {} - pkce-challenge@5.0.0: {} - pkg-dir@3.0.0: dependencies: find-up: 3.0.0 @@ -21240,13 +21005,6 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - raw-body@3.0.0: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.6.3 - unpipe: 1.0.0 - rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -21833,16 +21591,6 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0: - dependencies: - debug: 4.4.1(supports-color@8.1.1) - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.2.0 - transitivePeerDependencies: - - supports-color - rrweb-cssom@0.6.0: {} rrweb-cssom@0.8.0: {} @@ -22145,22 +21893,6 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.0: - dependencies: - debug: 4.4.1(supports-color@8.1.1) - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.0 - mime-types: 3.0.1 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - sentence-case@2.1.1: dependencies: no-case: 2.3.2 @@ -22179,15 +21911,6 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.0: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.0 - transitivePeerDependencies: - - supports-color - server-only@0.0.1: {} serwist@9.2.0(typescript@5.9.2): @@ -22475,8 +22198,6 @@ snapshots: statuses@2.0.1: {} - statuses@2.0.2: {} - std-env@3.9.0: {} stdin-discarder@0.2.2: {} @@ -23025,12 +22746,6 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.1 - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -23208,10 +22923,6 @@ snapshots: upper-case@1.1.3: {} - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - url-parse@1.5.10: dependencies: querystringify: 2.2.0 @@ -23407,7 +23118,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)(jiti@2.5.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.4(@types/node@24.3.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From ae0a24f740449f0bf323998b18e2c37c48fd3ca1 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:18:55 +0300 Subject: [PATCH 18/25] fix build --- apps/web/utils/actions/clean.ts | 1 - apps/web/utils/actions/mail.ts | 4 ++-- apps/web/utils/email/google.ts | 30 +++++------------------------- apps/web/utils/email/microsoft.ts | 27 +++++---------------------- apps/web/utils/email/types.ts | 14 +++++++------- 5 files changed, 19 insertions(+), 57 deletions(-) diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index b4abc86172..0e42386043 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -6,7 +6,6 @@ import { undoCleanInboxSchema, changeKeepToDoneSchema, } from "@/utils/actions/clean.validation"; -import { getThreadsWithNextPageToken } from "@/utils/gmail/thread"; import { bulkPublishToQstash } from "@/utils/upstash"; import { env } from "@/env"; import { diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index 7547a6eb93..29e3eabf93 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -220,7 +220,7 @@ export const sendEmailAction = actionClient return { success: true, - messageId: result.data.id, - threadId: result.data.threadId, + messageId: result.messageId, + threadId: result.threadId, }; }); diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index a021c8b368..0f221088ee 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -250,30 +250,6 @@ export class GmailProvider implements EmailProvider { } } - // async unarchiveMessage(messageId: string): Promise { - // await labelMessage({ - // gmail: this.client, - // messageId, - // addLabelIds: [GmailLabel.INBOX], - // }); - // } - - // async markMessageAsRead(messageId: string): Promise { - // await labelMessage({ - // gmail: this.client, - // messageId, - // removeLabelIds: [GmailLabel.UNREAD], - // }); - // } - - // async markMessageAsUnread(messageId: string): Promise { - // await labelMessage({ - // gmail: this.client, - // messageId, - // addLabelIds: [GmailLabel.UNREAD], - // }); - // } - async trashThread( threadId: string, ownerEmail: string, @@ -364,7 +340,11 @@ export class GmailProvider implements EmailProvider { contentType: string; }>; }) { - return await sendEmailWithHtml(this.client, body); + const result = await sendEmailWithHtml(this.client, body); + return { + messageId: result.data.id || "", + threadId: result.data.threadId || "", + }; } async forwardEmail( diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 9bd60f6d4d..a2c75b55c1 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -366,7 +366,11 @@ export class OutlookProvider implements EmailProvider { contentType: string; }>; }) { - return await sendEmailWithHtml(this.client, body); + const result = await sendEmailWithHtml(this.client, body); + return { + messageId: result.id || "", + threadId: result.conversationId || "", + }; } async forwardEmail( @@ -1178,27 +1182,6 @@ export class OutlookProvider implements EmailProvider { } } - // async unarchiveMessage(messageId: string): Promise { - // await this.client - // .getClient() - // .api(`/me/messages/${messageId}/move`) - // .post({ destinationId: "inbox" }); - // } - - // async markMessageAsRead(messageId: string): Promise { - // await this.client - // .getClient() - // .api(`/me/messages/${messageId}`) - // .patch({ isRead: true }); - // } - - // async markMessageAsUnread(messageId: string): Promise { - // await this.client - // .getClient() - // .api(`/me/messages/${messageId}`) - // .patch({ isRead: false }); - // } - async getOrCreateOutlookFolderIdByName(folderName: string): Promise { return await getOrCreateOutlookFolderIdByName(this.client, folderName); } diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index d8148fb6a3..47c3c5e69b 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -79,9 +79,6 @@ export interface EmailProvider { labelId?: string, ): Promise; archiveMessage(messageId: string): Promise; - // unarchiveMessage(messageId: string): Promise; - // markMessageAsRead(messageId: string): Promise; - // markMessageAsUnread(messageId: string): Promise; trashThread( threadId: string, ownerEmail: string, @@ -126,7 +123,10 @@ export interface EmailProvider { content: string; contentType: string; }>; - }): Promise; + }): Promise<{ + messageId: string; + threadId: string; + }>; forwardEmail( email: ParsedMessage, args: { to: string; cc?: string; bcc?: string; content?: string }, @@ -146,13 +146,13 @@ export interface EmailProvider { from: string; addLabelIds?: string[]; removeLabelIds?: string[]; - }): Promise; - deleteFilter(id: string): Promise; + }): Promise<{ status: number }>; + deleteFilter(id: string): Promise<{ status: number }>; createAutoArchiveFilter(options: { from: string; gmailLabelId?: string; labelName?: string; - }): Promise; + }): Promise<{ status: number }>; getMessagesWithPagination(options: { query?: string; maxResults?: number; From 710559143b902c59ae8f416c435757dd1565acd0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:20:15 +0300 Subject: [PATCH 19/25] fix tests --- apps/web/utils/api-auth.test.ts | 88 --------------------------------- 1 file changed, 88 deletions(-) diff --git a/apps/web/utils/api-auth.test.ts b/apps/web/utils/api-auth.test.ts index 6649f30dc4..507ee58dba 100644 --- a/apps/web/utils/api-auth.test.ts +++ b/apps/web/utils/api-auth.test.ts @@ -6,10 +6,8 @@ import { } from "./api-auth"; import prisma from "@/utils/__mocks__/prisma"; import { hashApiKey } from "@/utils/api-key"; -import * as gmailClient from "@/utils/gmail/client"; import { SafeError } from "@/utils/error"; import type { NextRequest } from "next/server"; -import type { gmail_v1 } from "@googleapis/gmail"; // Mock dependencies vi.mock("@/utils/prisma"); @@ -183,91 +181,5 @@ describe("api-auth", () => { "Missing access token", ); }); - - it("should throw an error if Gmail client refresh fails", async () => { - const request = { - headers: { - get: vi.fn().mockReturnValue("valid-api-key"), - }, - } as unknown as NextRequest; - - const mockUser = { - id: "user-id", - accounts: [ - { - access_token: "access-token", - refresh_token: "refresh-token", - expires_at: new Date(), - providerAccountId: "google-account-id", - }, - ], - }; - - vi.mocked(hashApiKey).mockReturnValue("hashed-key"); - (prisma.apiKey.findUnique as any).mockResolvedValue({ - user: mockUser, - isActive: true, - } as MockApiKeyResult); - - // Mock getGmailClientWithRefresh to return null (refresh failed) - vi.mocked(gmailClient.getGmailClientWithRefresh).mockRejectedValue( - new Error("Error refreshing Gmail access token"), - ); - - await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( - Error, - ); - await expect(validateApiKeyAndGetEmailProvider(request)).rejects.toThrow( - "Error refreshing Gmail access token", - ); - }); - - it("should return Gmail client and user ID if successful", async () => { - const request = { - headers: { - get: vi.fn().mockReturnValue("valid-api-key"), - }, - } as unknown as NextRequest; - - const mockUser = { - id: "user-id", - accounts: [ - { - access_token: "access-token", - refresh_token: "refresh-token", - expires_at: new Date(1_234_567_890 * 1000), - providerAccountId: "google-account-id", - }, - ], - }; - - vi.mocked(hashApiKey).mockReturnValue("hashed-key"); - (prisma.apiKey.findUnique as any).mockResolvedValue({ - user: mockUser, - isActive: true, - } as MockApiKeyResult); - - // Mock successful Gmail client refresh - const mockGmailClient = { - users: {}, - } as unknown as gmail_v1.Gmail; - vi.mocked(gmailClient.getGmailClientWithRefresh).mockResolvedValue( - mockGmailClient, - ); - - const result = await validateApiKeyAndGetEmailProvider(request); - expect(result).toEqual({ - accessToken: "access-token", - gmail: mockGmailClient, - userId: "user-id", - }); - - // Verify getGmailClientWithRefresh was called with correct parameters - expect(gmailClient.getGmailClientWithRefresh).toHaveBeenCalledWith({ - accessToken: "access-token", - refreshToken: "refresh-token", - expiresAt: 1_234_567_890 * 1000, - }); - }); }); }); From 14d0e737f32eb1ca415b04bef854830d4812e5cd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:26:02 +0300 Subject: [PATCH 20/25] fix --- apps/web/utils/email/microsoft.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index a2c75b55c1..26d8ab74ea 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -573,13 +573,13 @@ export class OutlookProvider implements EmailProvider { // Microsoft Graph API handles these differently const originalQuery = options.query || ""; - // Build date filter for Outlook (always quoted for OData) + // Build date filter for Outlook (no quotes for DateTimeOffset comparison) const dateFilters: string[] = []; if (options.before) { - dateFilters.push(`receivedDateTime lt '${options.before.toISOString()}'`); + dateFilters.push(`receivedDateTime lt ${options.before.toISOString()}`); } if (options.after) { - dateFilters.push(`receivedDateTime gt '${options.after.toISOString()}'`); + dateFilters.push(`receivedDateTime gt ${options.after.toISOString()}`); } logger.info("Query parameters separated", { @@ -873,12 +873,12 @@ export class OutlookProvider implements EmailProvider { // Handle structured date options if (after) { const afterISO = after.toISOString(); - filters.push(`receivedDateTime gt '${afterISO}'`); + filters.push(`receivedDateTime gt ${afterISO}`); } if (before) { const beforeISO = before.toISOString(); - filters.push(`receivedDateTime lt '${beforeISO}'`); + filters.push(`receivedDateTime lt ${beforeISO}`); } if (isUnread) { From bb6c2f9e66b68bbfe5d6502da28e469a2db1956e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:30:59 +0300 Subject: [PATCH 21/25] non optional args --- apps/web/utils/__mocks__/email-provider.ts | 1 + apps/web/utils/email/google.ts | 9 ++++++--- apps/web/utils/email/microsoft.ts | 11 +++++++---- apps/web/utils/email/types.ts | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts index 8446655fb3..934ab6ae56 100644 --- a/apps/web/utils/__mocks__/email-provider.ts +++ b/apps/web/utils/__mocks__/email-provider.ts @@ -128,6 +128,7 @@ export const createMockEmailProvider = ( .mockResolvedValue({ messages: [], nextPageToken: undefined }), getOrCreateOutlookFolderIdByName: vi.fn().mockResolvedValue("folder1"), sendEmailWithHtml: vi.fn().mockResolvedValue(undefined), + getDrafts: vi.fn().mockResolvedValue([]), ...overrides, }); diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 0f221088ee..55525eba09 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -150,14 +150,17 @@ export class GmailProvider implements EmailProvider { return parseMessage(message); } - async getMessages(options?: { + async getMessages({ + searchQuery, + maxResults, + }: { searchQuery?: string; folderId?: string; maxResults?: number; }): Promise { const response = await getMessages(this.client, { - query: options?.searchQuery, - maxResults: options?.maxResults ?? 50, + query: searchQuery, + maxResults: maxResults ?? 50, }); const messages = response.messages || []; return messages diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 26d8ab74ea..06b1da0ff7 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -139,20 +139,23 @@ export class OutlookProvider implements EmailProvider { } } - async getMessages(options?: { + async getMessages({ + searchQuery, + maxResults = 50, + folderId, + }: { searchQuery?: string; folderId?: string; maxResults?: number; }): Promise { - const maxResults = options?.maxResults ?? 50; const allMessages: ParsedMessage[] = []; let pageToken: string | undefined; const pageSize = 20; // Outlook API limit while (allMessages.length < maxResults) { const response = await queryBatchMessages(this.client, { - searchQuery: options?.searchQuery, - folderId: options?.folderId, + searchQuery, + folderId, maxResults: Math.min(pageSize, maxResults - allMessages.length), pageToken, }); diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 47c3c5e69b..840b8f460c 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -40,7 +40,7 @@ export interface EmailProvider { getLabels(): Promise; getLabelById(labelId: string): Promise; getMessage(messageId: string): Promise; - getMessages(options?: { + getMessages(options: { searchQuery?: string; folderId?: string; maxResults?: number; From c1fc5e8acb6b7c30f74617eed9c7a7c1b7102fd8 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:40:47 +0300 Subject: [PATCH 22/25] remove unused code --- .../app/api/user/stats/recipients/route.ts | 37 ------------------- apps/web/utils/__mocks__/email-provider.ts | 1 - apps/web/utils/actions/user.ts | 1 + apps/web/utils/email/google.ts | 18 --------- apps/web/utils/email/microsoft.ts | 2 +- apps/web/utils/email/types.ts | 5 --- apps/web/utils/gmail/message.ts | 19 +++++++++- 7 files changed, 19 insertions(+), 64 deletions(-) diff --git a/apps/web/app/api/user/stats/recipients/route.ts b/apps/web/app/api/user/stats/recipients/route.ts index f475eba4f4..4f4e6311ce 100644 --- a/apps/web/app/api/user/stats/recipients/route.ts +++ b/apps/web/app/api/user/stats/recipients/route.ts @@ -1,10 +1,5 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import countBy from "lodash/countBy"; -import sortBy from "lodash/sortBy"; -import type { gmail_v1 } from "@googleapis/gmail"; -import { parseMessage } from "@/utils/gmail/message"; -import { getMessage, getMessages } from "@/utils/gmail/message"; import { withEmailAccount } from "@/utils/middleware"; import { getEmailFieldStats } from "@/app/api/user/stats/helpers"; @@ -18,38 +13,6 @@ export interface RecipientsResponse { mostActiveRecipientEmails: { name: string; value: number }[]; } -async function _getRecipients({ - gmail, -}: { - gmail: gmail_v1.Gmail; -}): Promise { - const res = await getMessages(gmail, { - query: "in:sent", - maxResults: 50, - }); - - // be careful of rate limiting here - const messages = await Promise.all( - res.messages?.map(async (m) => { - // TODO: Use email provider to get the message which will parse it internally - const message = await getMessage(m.id!, gmail); - return parseMessage(message); - }) || [], - ); - - const countByRecipient = countBy(messages, (m) => m.headers.to); - - const mostActiveRecipientEmails = sortBy( - Object.entries(countByRecipient), - ([, count]) => -count, - ).map(([recipient, count]) => ({ - name: recipient, - value: count, - })); - - return { mostActiveRecipientEmails }; -} - async function getRecipientStatistics( options: RecipientStatsQuery & { emailAccountId: string }, ): Promise { diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts index 934ab6ae56..ca9ba4446c 100644 --- a/apps/web/utils/__mocks__/email-provider.ts +++ b/apps/web/utils/__mocks__/email-provider.ts @@ -63,7 +63,6 @@ export const createMockEmailProvider = ( inline: [], labelIds: [], }), - getMessages: vi.fn().mockResolvedValue([]), getSentMessages: vi.fn().mockResolvedValue([]), getSentThreadsExcluding: vi.fn().mockResolvedValue([]), getThreadMessages: vi.fn().mockResolvedValue([]), diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index 5b7587ae4c..8cdc5caf2b 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -41,6 +41,7 @@ export const saveSignatureAction = actionClient }); }); +// TODO: Use Gmail API to fetch signatures instead export const loadSignatureFromGmailAction = actionClient .metadata({ name: "loadSignatureFromGmail" }) .action(async ({ ctx: { emailAccountId } }) => { diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 55525eba09..3b95ab000e 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -150,24 +150,6 @@ export class GmailProvider implements EmailProvider { return parseMessage(message); } - async getMessages({ - searchQuery, - maxResults, - }: { - searchQuery?: string; - folderId?: string; - maxResults?: number; - }): Promise { - const response = await getMessages(this.client, { - query: searchQuery, - maxResults: maxResults ?? 50, - }); - const messages = response.messages || []; - return messages - .filter((message) => isDefined(message.payload)) - .map((message) => parseMessage(message as MessageWithPayload)); - } - async getSentMessages(maxResults = 20): Promise { return getSentMessages(this.client, maxResults); } diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 06b1da0ff7..a06d988c18 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -139,7 +139,7 @@ export class OutlookProvider implements EmailProvider { } } - async getMessages({ + private async getMessages({ searchQuery, maxResults = 50, folderId, diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 840b8f460c..51fa3d86d1 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -40,11 +40,6 @@ export interface EmailProvider { getLabels(): Promise; getLabelById(labelId: string): Promise; getMessage(messageId: string): Promise; - getMessages(options: { - searchQuery?: string; - folderId?: string; - maxResults?: number; - }): Promise; getMessagesByFields(options: { froms?: string[]; tos?: string[]; diff --git a/apps/web/utils/gmail/message.ts b/apps/web/utils/gmail/message.ts index 1ff6c30dd2..c492f48843 100644 --- a/apps/web/utils/gmail/message.ts +++ b/apps/web/utils/gmail/message.ts @@ -257,7 +257,13 @@ export async function getMessages( pageToken?: string; labelIds?: string[]; }, -) { +): Promise<{ + messages: { + id: string; + threadId: string; + }[]; + nextPageToken?: string; +}> { const messages = await gmail.users.messages.list({ userId: "me", maxResults: options.maxResults, @@ -266,7 +272,16 @@ export async function getMessages( labelIds: options.labelIds, }); - return messages.data; + return { + messages: messages.data.messages?.filter(isMessage) || [], + nextPageToken: messages.data.nextPageToken || undefined, + }; +} + +function isMessage( + message: gmail_v1.Schema$Message, +): message is { id: string; threadId: string } { + return !!message.id && !!message.threadId; } export async function queryBatchMessages( From 0bcbfd2ac244d88c7a5e3ac8602e24d89878dc2b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:41:55 +0300 Subject: [PATCH 23/25] v2.12.0 --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index d6509f096f..9f94b801a3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.11.1 +v2.12.0 From 9720eb7d244ed7d0a1981b9ee437e74e8d01ca00 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:53:35 +0300 Subject: [PATCH 24/25] fixes --- .../api/user/rules/[id]/example/controller.ts | 10 +- apps/web/utils/email/microsoft.ts | 16 ++- apps/web/utils/outlook/message.test.ts | 111 ++++++++++++++++++ apps/web/utils/outlook/message.ts | 58 +++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 apps/web/utils/outlook/message.test.ts diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts index f966c8e516..e5314376a7 100644 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ b/apps/web/app/api/user/rules/[id]/example/controller.ts @@ -60,10 +60,16 @@ async function fetchStaticExampleMessages( }; if (rule.from) { - options.froms = [rule.from]; + options.froms = rule.from + .split("|") + .map((s) => s.trim()) + .filter(Boolean); } if (rule.to) { - options.tos = [rule.to]; + options.tos = rule.to + .split("|") + .map((s) => s.trim()) + .filter(Boolean); } if (rule.subject) { options.subjects = [rule.subject]; diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index a06d988c18..4c81fe2e50 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -61,6 +61,7 @@ import { unwatchOutlook, watchOutlook } from "@/utils/outlook/watch"; import { escapeODataString } from "@/utils/outlook/odata-escape"; import { extractEmailAddress } from "@/utils/email"; import { getOrCreateOutlookFolderIdByName } from "@/utils/outlook/folders"; +import { hasUnquotedParentFolderId } from "@/utils/outlook/message"; const logger = createScopedLogger("outlook-provider"); @@ -592,20 +593,29 @@ export class OutlookProvider implements EmailProvider { hasDateFilters: dateFilters.length > 0, }); + // Check if the query already contains parentFolderId as an unquoted identifier + // If it does, skip applying the default folder filter to avoid conflicts + const queryHasParentFolderId = + originalQuery && hasUnquotedParentFolderId(originalQuery); + // Get folder IDs to get the inbox folder ID const folderIds = await getFolderIds(this.client); const inboxFolderId = folderIds.inbox; - if (!inboxFolderId) { + if (!queryHasParentFolderId && !inboxFolderId) { throw new Error("Could not find inbox folder ID"); } + // Only apply folder filtering if the query doesn't already specify parentFolderId + const folderId = queryHasParentFolderId ? undefined : inboxFolderId; + logger.info("Calling queryBatchMessages with separated parameters", { searchQuery: originalQuery.trim() || undefined, dateFilters, maxResults: options.maxResults || 20, pageToken: options.pageToken, - folderId: inboxFolderId, + folderId, + queryHasParentFolderId, }); const response = await queryBatchMessages(this.client, { @@ -613,7 +623,7 @@ export class OutlookProvider implements EmailProvider { dateFilters, maxResults: options.maxResults || 20, pageToken: options.pageToken, - folderId: inboxFolderId, + folderId, }); return { diff --git a/apps/web/utils/outlook/message.test.ts b/apps/web/utils/outlook/message.test.ts new file mode 100644 index 0000000000..cfde25ddde --- /dev/null +++ b/apps/web/utils/outlook/message.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { hasUnquotedParentFolderId } from "./message"; + +describe("hasUnquotedParentFolderId", () => { + describe("should return true for unquoted parentFolderId", () => { + it("detects direct parentFolderId filter", () => { + const query = "parentFolderId eq 'inbox'"; + expect(hasUnquotedParentFolderId(query)).toBe(true); + }); + + it("detects parentFolderId in compound query", () => { + const query = + "from eq 'test@example.com' and parentFolderId eq 'archive'"; + expect(hasUnquotedParentFolderId(query)).toBe(true); + }); + + it("detects parentFolderId with 'ne' operator", () => { + const query = "parentFolderId ne 'drafts'"; + expect(hasUnquotedParentFolderId(query)).toBe(true); + }); + + it("detects parentFolderId in or conditions", () => { + const query = + "(parentFolderId eq 'inbox' or parentFolderId eq 'archive')"; + expect(hasUnquotedParentFolderId(query)).toBe(true); + }); + }); + + describe("should return false for quoted parentFolderId", () => { + it("ignores parentFolderId inside single quotes", () => { + const query = "subject eq 'parentFolderId eq 123'"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("ignores parentFolderId inside double quotes", () => { + const query = 'subject eq "parentFolderId eq 123"'; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("ignores parentFolderId in quoted subject", () => { + const query = + "from eq 'user@domain.com' and subject eq 'Re: parentFolderId issues'"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("ignores parentFolderId in quoted body search", () => { + const query = "body contains 'This email mentions parentFolderId'"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("handles escaped quotes correctly", () => { + const query = "subject eq 'with \\'escaped\\' quotes and parentFolderId'"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("handles mixed single and double quotes", () => { + const query = `subject eq "outer 'inner parentFolderId' outer"`; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + }); + + describe("mixed cases", () => { + it("detects unquoted parentFolderId even when quoted ones exist", () => { + const query = + "subject eq 'test parentFolderId' and parentFolderId eq 'inbox'"; + expect(hasUnquotedParentFolderId(query)).toBe(true); + }); + + it("handles complex nested quotes", () => { + const query = `subject eq "complex 'nested parentFolderId' quotes" and from eq 'test@example.com'`; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + }); + + describe("edge cases", () => { + it("returns false for empty query", () => { + const query = ""; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("returns false for query without parentFolderId", () => { + const query = "from eq 'test@example.com'"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("handles partial matches correctly", () => { + const query = "myparentFolderId eq 'test'"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("handles word boundaries correctly", () => { + const query = "parentFolderIdSomething eq 'test'"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + + it("detects parentFolderId at start of query", () => { + const query = "parentFolderId eq 'inbox' and from eq 'test@example.com'"; + expect(hasUnquotedParentFolderId(query)).toBe(true); + }); + + it("detects parentFolderId at end of query", () => { + const query = "from eq 'test@example.com' and parentFolderId eq 'inbox'"; + expect(hasUnquotedParentFolderId(query)).toBe(true); + }); + + it("handles unclosed quotes gracefully", () => { + const query = "subject eq 'unclosed quote and parentFolderId"; + expect(hasUnquotedParentFolderId(query)).toBe(false); + }); + }); +}); diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index 9c36d78d53..93288eaab5 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -7,6 +7,64 @@ import { escapeODataString } from "@/utils/outlook/odata-escape"; const logger = createScopedLogger("outlook/message"); +/** + * Removes quoted string literals from a query string to avoid false positives + * when checking for identifiers that might appear inside quotes. + * Handles both single and double quotes, including escaped quotes. + */ +function stripQuotedLiterals(query: string): string { + let result = ""; + let i = 0; + + while (i < query.length) { + const char = query[i]; + + if (char === "'" || char === '"') { + const quote = char; + i++; // Skip opening quote + + // Skip until we find the matching closing quote (or end of string) + while (i < query.length) { + const current = query[i]; + if (current === quote) { + // Found closing quote, check if it's escaped + let backslashCount = 0; + let j = i - 1; + while (j >= 0 && query[j] === "\\") { + backslashCount++; + j--; + } + + // If even number of backslashes (including 0), quote is not escaped + if (backslashCount % 2 === 0) { + i++; // Skip closing quote + break; + } + } + i++; + } + + // Replace the entire quoted section with spaces to maintain positions + // This prevents issues with adjacent identifiers + result += " "; + } else { + result += char; + i++; + } + } + + return result; +} + +/** + * Checks if parentFolderId appears as an unquoted identifier in the query. + * This avoids false positives when parentFolderId appears inside string literals. + */ +export function hasUnquotedParentFolderId(query: string): boolean { + const cleanedQuery = stripQuotedLiterals(query); + return /\bparentFolderId\b/.test(cleanedQuery); +} + // Cache for folder IDs let folderIdCache: Record | null = null; From e28050874bf5ee8aaeb00e11670048d21a07cadd Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:55:40 +0300 Subject: [PATCH 25/25] delete old file and clean up --- apps/web/app/api/google/labels/route.ts | 51 ------------------------- apps/web/utils/actions/clean.ts | 4 +- 2 files changed, 3 insertions(+), 52 deletions(-) delete mode 100644 apps/web/app/api/google/labels/route.ts diff --git a/apps/web/app/api/google/labels/route.ts b/apps/web/app/api/google/labels/route.ts deleted file mode 100644 index 1de9a3250f..0000000000 --- a/apps/web/app/api/google/labels/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { gmail_v1 } from "@googleapis/gmail"; -import { NextResponse } from "next/server"; -import { withEmailProvider } from "@/utils/middleware"; -import { createEmailProvider } from "@/utils/email/provider"; -import type { EmailProvider } from "@/utils/email/types"; - -export const dynamic = "force-dynamic"; -export const maxDuration = 30; - -export type UnifiedLabel = { - id: string; - name: string; - type: string | null; - color?: { - textColor?: string | null; - backgroundColor?: string | null; - }; - labelListVisibility?: string; - messageListVisibility?: string; -}; - -export type LabelsResponse = { - labels: UnifiedLabel[]; -}; - -function isUserLabel(label: gmail_v1.Schema$Label): boolean { - return label.type === "user"; -} - -async function getLabels( - emailProvider: EmailProvider, -): Promise { - const labels = await emailProvider.getLabels(); - - const unifiedLabels: UnifiedLabel[] = (labels || []).filter((label) => - isUserLabel(label), - ); - - return { labels: unifiedLabels }; -} - -export const GET = withEmailProvider(async (request) => { - const emailAccountId = request.auth.emailAccountId; - const provider = request.emailProvider.name; - - const emailProvider = await createEmailProvider({ emailAccountId, provider }); - - const labels = await getLabels(emailProvider); - - return NextResponse.json(labels); -}); diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index 0e42386043..eddb34f3a7 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -117,7 +117,9 @@ export const cleanInboxAction = actionClient const { threads, nextPageToken: pageToken } = await emailProvider.getThreadsWithQuery({ query: { - before: new Date(Date.now() - daysOld * ONE_DAY_MS), + ...(daysOld > 0 && { + before: new Date(Date.now() - daysOld * ONE_DAY_MS), + }), labelIds: type === "inbox" ? [GmailLabel.INBOX]