From e21cbace81ce43c809ad084e0b99dec3e73d3380 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 2 Nov 2025 23:39:34 +0200 Subject: [PATCH 1/7] Find message id from rfc822 id --- .../app/(app)/[emailAccountId]/mail/page.tsx | 17 +-- apps/web/app/(app)/admin/AdminHashEmail.tsx | 84 +++++++------- .../web/app/(app)/admin/GmailUrlConverter.tsx | 103 ++++++++++++++++++ apps/web/app/(app)/admin/page.tsx | 2 + apps/web/utils/actions/admin.ts | 61 ++++++++++- apps/web/utils/actions/admin.validation.ts | 6 + apps/web/utils/email/google.ts | 8 ++ apps/web/utils/email/microsoft.ts | 23 ++++ apps/web/utils/email/types.ts | 3 + apps/web/utils/gmail/gmail-id.ts | 14 +++ version.txt | 2 +- 11 files changed, 275 insertions(+), 48 deletions(-) create mode 100644 apps/web/app/(app)/admin/GmailUrlConverter.tsx create mode 100644 apps/web/utils/gmail/gmail-id.ts diff --git a/apps/web/app/(app)/[emailAccountId]/mail/page.tsx b/apps/web/app/(app)/[emailAccountId]/mail/page.tsx index d4d30b5ad2..e1b27f583d 100644 --- a/apps/web/app/(app)/[emailAccountId]/mail/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/mail/page.tsx @@ -16,14 +16,6 @@ export default function Mail(props: { searchParams: Promise<{ type?: string; labelId?: string }>; }) { const searchParams = use(props.searchParams); - const query: ThreadsQuery = {}; - - // Handle different query params - if (searchParams.type === "label" && searchParams.labelId) { - query.labelId = searchParams.labelId; - } else if (searchParams.type) { - query.type = searchParams.type; - } const getKey = ( pageIndex: number, @@ -31,6 +23,15 @@ export default function Mail(props: { ) => { if (previousPageData && !previousPageData.nextPageToken) return null; + const query: ThreadsQuery = {}; + + // Handle different query params + if (searchParams.type === "label" && searchParams.labelId) { + query.labelId = searchParams.labelId; + } else if (searchParams.type) { + query.type = searchParams.type; + } + // Append nextPageToken for subsequent pages if (pageIndex > 0 && previousPageData?.nextPageToken) { query.nextPageToken = previousPageData.nextPageToken; diff --git a/apps/web/app/(app)/admin/AdminHashEmail.tsx b/apps/web/app/(app)/admin/AdminHashEmail.tsx index 9b9eb6abf7..08b06ca345 100644 --- a/apps/web/app/(app)/admin/AdminHashEmail.tsx +++ b/apps/web/app/(app)/admin/AdminHashEmail.tsx @@ -5,6 +5,7 @@ import { useForm, type SubmitHandler } from "react-hook-form"; import { useAction } from "next-safe-action/hooks"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/Input"; import { toastSuccess, toastError } from "@/components/Toast"; import { adminHashEmailAction } from "@/utils/actions/admin"; @@ -51,45 +52,52 @@ export const AdminHashEmail = () => { }; return ( -
-
-

Hash for Log Search

-
+ + + Hash for Log Search + + + + - + - - - {result.data?.hash && ( -
-
- -
-
- -
-
- )} - + {result.data?.hash && ( +
+
+ +
+
+ +
+
+ )} + +
+
); }; diff --git a/apps/web/app/(app)/admin/GmailUrlConverter.tsx b/apps/web/app/(app)/admin/GmailUrlConverter.tsx new file mode 100644 index 0000000000..da7c7381e5 --- /dev/null +++ b/apps/web/app/(app)/admin/GmailUrlConverter.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useCallback } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { useAction } from "next-safe-action/hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/Input"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { toastSuccess, toastError } from "@/components/Toast"; +import { adminConvertGmailUrlAction } from "@/utils/actions/admin"; +import { + convertGmailUrlBody, + type ConvertGmailUrlBody, +} from "@/utils/actions/admin.validation"; + +export function GmailUrlConverter() { + const { + execute: convertUrl, + isExecuting, + result, + } = useAction(adminConvertGmailUrlAction, { + onSuccess: () => { + toastSuccess({ description: "Message found!" }); + }, + onError: ({ error }) => { + toastError({ + title: "Error looking up message", + description: error.serverError || "An error occurred", + }); + }, + }); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(convertGmailUrlBody), + }); + + const onSubmit: SubmitHandler = useCallback( + (data) => { + convertUrl(data); + }, + [convertUrl], + ); + + return ( + + + Email Message Lookup + + Find thread/message IDs using RFC822 Message-ID from email headers + + + +
+ + + +
+ + {result.data && ( +
+
+ Thread ID: + {result.data.threadId} +
+
+ Message IDs: + + {result.data.messageIds.join(", ")} + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/(app)/admin/page.tsx b/apps/web/app/(app)/admin/page.tsx index 49a127cde0..a9e84bf0b1 100644 --- a/apps/web/app/(app)/admin/page.tsx +++ b/apps/web/app/(app)/admin/page.tsx @@ -10,6 +10,7 @@ import { } from "@/app/(app)/admin/AdminSyncStripe"; import { RegisterSSOModal } from "@/app/(app)/admin/RegisterSSOModal"; import { AdminHashEmail } from "@/app/(app)/admin/AdminHashEmail"; +import { GmailUrlConverter } from "@/app/(app)/admin/GmailUrlConverter"; // NOTE: Turn on Fluid Compute on Vercel to allow for 800 seconds max duration export const maxDuration = 800; @@ -34,6 +35,7 @@ export default async function AdminPage() { +
diff --git a/apps/web/utils/actions/admin.ts b/apps/web/utils/actions/admin.ts index 60dbfce39a..577fa76528 100644 --- a/apps/web/utils/actions/admin.ts +++ b/apps/web/utils/actions/admin.ts @@ -10,7 +10,10 @@ import { syncStripeDataToDb } from "@/ee/billing/stripe/sync-stripe"; import { getStripe } from "@/ee/billing/stripe"; import { createEmailProvider } from "@/utils/email/provider"; import { hash } from "@/utils/hash"; -import { hashEmailBody } from "@/utils/actions/admin.validation"; +import { + hashEmailBody, + convertGmailUrlBody, +} from "@/utils/actions/admin.validation"; export const adminProcessHistoryAction = adminActionClient .metadata({ name: "adminProcessHistory" }) @@ -196,3 +199,59 @@ export const adminHashEmailAction = adminActionClient const hashed = hash(email); return { hash: hashed }; }); + +export const adminConvertGmailUrlAction = adminActionClient + .metadata({ name: "adminConvertGmailUrl" }) + .schema(convertGmailUrlBody) + .action(async ({ parsedInput: { rfc822MessageId, email } }) => { + // Clean up Message-ID (remove < > if present) + const cleanMessageId = rfc822MessageId.trim().replace(/^<|>$/g, ""); + + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email: email.toLowerCase() }, + select: { + id: true, + account: { + select: { + provider: true, + }, + }, + }, + }); + + if (!emailAccount) { + throw new SafeError("Email account not found"); + } + + const emailProvider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: emailAccount.account.provider, + }); + + const message = + await emailProvider.getMessageByRfc822MessageId(cleanMessageId); + + if (!message) { + throw new SafeError( + `Could not find message with RFC822 Message-ID: ${cleanMessageId}`, + ); + } + + if (!message.threadId) { + throw new SafeError("Message does not have a thread ID"); + } + + const thread = await emailProvider.getThread(message.threadId); + + if (!thread) { + throw new SafeError("Could not find thread for message"); + } + + const messageIds = thread.messages?.map((m) => m.id) || []; + + return { + threadId: thread.id, + messageIds: messageIds, + rfc822MessageId: cleanMessageId, + }; + }); diff --git a/apps/web/utils/actions/admin.validation.ts b/apps/web/utils/actions/admin.validation.ts index ce705b974f..e8da18b4ec 100644 --- a/apps/web/utils/actions/admin.validation.ts +++ b/apps/web/utils/actions/admin.validation.ts @@ -4,3 +4,9 @@ export const hashEmailBody = z.object({ email: z.string().min(1, "Value is required"), }); export type HashEmailBody = z.infer; + +export const convertGmailUrlBody = z.object({ + rfc822MessageId: z.string().min(1, "RFC822 Message-ID is required"), + email: z.string().email("Valid email address is required"), +}); +export type ConvertGmailUrlBody = z.infer; diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 27137aca1b..0e9f8cdb61 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -160,6 +160,14 @@ export class GmailProvider implements EmailProvider { return parseMessage(message); } + async getMessageByRfc822MessageId( + rfc822MessageId: string, + ): Promise { + const message = await getMessageByRfc822Id(rfc822MessageId, this.client); + if (!message) return null; + return parseMessage(message); + } + 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 88f8310f39..cdf8c33d6f 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -6,6 +6,7 @@ import { getMessages, queryBatchMessages, getFolderIds, + convertMessage, } from "@/utils/outlook/message"; import { getLabels, @@ -151,6 +152,28 @@ export class OutlookProvider implements EmailProvider { } } + async getMessageByRfc822MessageId( + rfc822MessageId: string, + ): Promise { + const cleanMessageId = rfc822MessageId.trim().replace(/^<|>$/g, ""); + const messageIdWithBrackets = `<${cleanMessageId}>`; + + const response = await this.client + .getClient() + .api("/me/messages") + .filter(`internetMessageId eq '${messageIdWithBrackets}'`) + .top(1) + .get(); + + const message = response.value?.[0]; + if (!message) { + return null; + } + + const folderIds = await getFolderIds(this.client); + return convertMessage(message, folderIds); + } + private async getMessages({ searchQuery, maxResults = 50, diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index d0b0911bea..782f7029bb 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -50,6 +50,9 @@ export interface EmailProvider { getLabelByName(name: string): Promise; getFolders(): Promise; getMessage(messageId: string): Promise; + getMessageByRfc822MessageId( + rfc822MessageId: string, + ): Promise; getMessagesByFields(options: { froms?: string[]; tos?: string[]; diff --git a/apps/web/utils/gmail/gmail-id.ts b/apps/web/utils/gmail/gmail-id.ts new file mode 100644 index 0000000000..d3008162e8 --- /dev/null +++ b/apps/web/utils/gmail/gmail-id.ts @@ -0,0 +1,14 @@ +/** + * Gmail ID utilities + */ + +/** + * Extract Gmail ID from a Gmail URL + * @param url - Gmail URL + * @returns Gmail ID (either URL format or API format, depending on what's in the URL) + */ +export function extractGmailIdFromUrl(url: string): string | null { + // Match pattern: #label/ID or #search/query/ID + const match = url.match(/#[^/]+\/([A-Za-z0-9_-]+)/); + return match ? match[1] : null; +} diff --git a/version.txt b/version.txt index 5f4b2ff1c8..c2be048130 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.17.25 +v2.17.26 From af2e1a11ba937cc3b4b0c0766f80712ef190a6b1 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 2 Nov 2025 23:42:36 +0200 Subject: [PATCH 2/7] fix mocl --- apps/web/utils/__mocks__/email-provider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts index c149e793e8..3dd1e12f18 100644 --- a/apps/web/utils/__mocks__/email-provider.ts +++ b/apps/web/utils/__mocks__/email-provider.ts @@ -45,6 +45,8 @@ export const createMockEmailProvider = ( getLabels: vi.fn().mockResolvedValue([]), getLabelById: vi.fn().mockResolvedValue(null), getLabelByName: vi.fn().mockResolvedValue(null), + getMessageByRfc822MessageId: vi.fn().mockResolvedValue(null), + getFolders: vi.fn().mockResolvedValue([]), getSignatures: vi.fn().mockResolvedValue([]), getMessage: vi.fn().mockResolvedValue({ id: "msg1", From 3f468785a409e848cfc299ff6c93d5742ed67335 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 2 Nov 2025 23:44:25 +0200 Subject: [PATCH 3/7] delete unused file --- apps/web/utils/gmail/gmail-id.ts | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 apps/web/utils/gmail/gmail-id.ts diff --git a/apps/web/utils/gmail/gmail-id.ts b/apps/web/utils/gmail/gmail-id.ts deleted file mode 100644 index d3008162e8..0000000000 --- a/apps/web/utils/gmail/gmail-id.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Gmail ID utilities - */ - -/** - * Extract Gmail ID from a Gmail URL - * @param url - Gmail URL - * @returns Gmail ID (either URL format or API format, depending on what's in the URL) - */ -export function extractGmailIdFromUrl(url: string): string | null { - // Match pattern: #label/ID or #search/query/ID - const match = url.match(/#[^/]+\/([A-Za-z0-9_-]+)/); - return match ? match[1] : null; -} From b11703d688c5a59ecf05c2b99e1816319485b3cc Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 3 Nov 2025 01:56:50 +0200 Subject: [PATCH 4/7] fixes --- apps/web/utils/actions/admin.validation.ts | 4 ++-- apps/web/utils/email/microsoft.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/utils/actions/admin.validation.ts b/apps/web/utils/actions/admin.validation.ts index e8da18b4ec..107877d2a5 100644 --- a/apps/web/utils/actions/admin.validation.ts +++ b/apps/web/utils/actions/admin.validation.ts @@ -6,7 +6,7 @@ export const hashEmailBody = z.object({ export type HashEmailBody = z.infer; export const convertGmailUrlBody = z.object({ - rfc822MessageId: z.string().min(1, "RFC822 Message-ID is required"), - email: z.string().email("Valid email address is required"), + rfc822MessageId: z.string().trim().min(1, "RFC822 Message-ID is required"), + email: z.string().trim().email("Valid email address is required"), }); export type ConvertGmailUrlBody = z.infer; diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index cdf8c33d6f..6ccb163614 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -161,7 +161,9 @@ export class OutlookProvider implements EmailProvider { const response = await this.client .getClient() .api("/me/messages") - .filter(`internetMessageId eq '${messageIdWithBrackets}'`) + .filter( + `internetMessageId eq '${escapeODataString(messageIdWithBrackets)}'`, + ) .top(1) .get(); From 72eb58c435be3f4185e81b3bfc05cca50f47dbff Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 3 Nov 2025 01:57:15 +0200 Subject: [PATCH 5/7] ui --- apps/web/components/NavUser.tsx | 8 ++++++++ apps/web/components/SideNav.tsx | 7 ------- apps/web/utils/actions/email-account-cookie.ts | 4 ++-- apps/web/utils/actions/user.ts | 11 +++++++---- apps/web/utils/cookies.server.ts | 7 +++++++ 5 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 apps/web/utils/cookies.server.ts diff --git a/apps/web/components/NavUser.tsx b/apps/web/components/NavUser.tsx index bf42cb0aaf..67630d4b21 100644 --- a/apps/web/components/NavUser.tsx +++ b/apps/web/components/NavUser.tsx @@ -11,6 +11,7 @@ import { PaletteIcon, ChromeIcon, Building2Icon, + CrownIcon, } from "lucide-react"; import { DropdownMenu, @@ -174,6 +175,13 @@ export function NavUser() { Usage + + + + + Premium + + diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index 6597a4ccb8..7ef61f2791 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -247,13 +247,6 @@ export function SideNav({ ...props }: React.ComponentProps) { - - - - Premium - - - diff --git a/apps/web/utils/actions/email-account-cookie.ts b/apps/web/utils/actions/email-account-cookie.ts index 7dd262243b..a20b4a84a0 100644 --- a/apps/web/utils/actions/email-account-cookie.ts +++ b/apps/web/utils/actions/email-account-cookie.ts @@ -6,6 +6,7 @@ import { LAST_EMAIL_ACCOUNT_COOKIE, type LastEmailAccountCookieValue, } from "@/utils/cookies"; +import { clearLastEmailAccountCookie } from "@/utils/cookies.server"; import { actionClientUser } from "@/utils/actions/safe-action"; /** @@ -40,6 +41,5 @@ export const setLastEmailAccountAction = actionClientUser export const clearLastEmailAccountAction = actionClientUser .metadata({ name: "clearLastEmailAccount" }) .action(async () => { - const cookieStore = await cookies(); - cookieStore.delete(LAST_EMAIL_ACCOUNT_COOKIE); + await clearLastEmailAccountCookie(); }); diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index 47b8607b9c..c04f0910af 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -13,6 +13,7 @@ import { saveAboutBody, saveSignatureBody, } from "@/utils/actions/user.validation"; +import { clearLastEmailAccountCookie } from "@/utils/cookies.server"; export const saveAboutAction = actionClient .metadata({ name: "saveAbout" }) @@ -45,11 +46,13 @@ export const resetAnalyticsAction = actionClient export const deleteAccountAction = actionClientUser .metadata({ name: "deleteAccount" }) .action(async ({ ctx: { userId } }) => { - try { - await betterAuthConfig.api.signOut({ + clearLastEmailAccountCookie().catch(() => {}); + + await betterAuthConfig.api + .signOut({ headers: await headers(), - }); - } catch {} + }) + .catch(() => {}); await deleteUser({ userId }); }); diff --git a/apps/web/utils/cookies.server.ts b/apps/web/utils/cookies.server.ts new file mode 100644 index 0000000000..88dced2142 --- /dev/null +++ b/apps/web/utils/cookies.server.ts @@ -0,0 +1,7 @@ +import { cookies } from "next/headers"; +import { LAST_EMAIL_ACCOUNT_COOKIE } from "@/utils/cookies"; + +export async function clearLastEmailAccountCookie() { + const cookieStore = await cookies(); + cookieStore.delete(LAST_EMAIL_ACCOUNT_COOKIE); +} From 8840172c30245bc1c365083e91fc7faf8d25a35b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:02:48 +0200 Subject: [PATCH 6/7] fix --- apps/web/utils/actions/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index c04f0910af..1544b5e4bf 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -46,7 +46,7 @@ export const resetAnalyticsAction = actionClient export const deleteAccountAction = actionClientUser .metadata({ name: "deleteAccount" }) .action(async ({ ctx: { userId } }) => { - clearLastEmailAccountCookie().catch(() => {}); + await clearLastEmailAccountCookie().catch(() => {}); await betterAuthConfig.api .signOut({ From 2d0dc3bf05d3131cda61fb775655e3a43c90a245 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 3 Nov 2025 02:03:52 +0200 Subject: [PATCH 7/7] fix --- apps/web/utils/actions/user.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index 1544b5e4bf..373a64baaa 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -17,7 +17,7 @@ import { clearLastEmailAccountCookie } from "@/utils/cookies.server"; export const saveAboutAction = actionClient .metadata({ name: "saveAbout" }) - .schema(saveAboutBody) + .inputSchema(saveAboutBody) .action(async ({ parsedInput: { about }, ctx: { emailAccountId } }) => { await prisma.emailAccount.update({ where: { id: emailAccountId }, @@ -27,7 +27,7 @@ export const saveAboutAction = actionClient export const saveSignatureAction = actionClient .metadata({ name: "saveSignature" }) - .schema(saveSignatureBody) + .inputSchema(saveSignatureBody) .action(async ({ parsedInput: { signature }, ctx: { emailAccountId } }) => { await prisma.emailAccount.update({ where: { id: emailAccountId }, @@ -45,20 +45,24 @@ export const resetAnalyticsAction = actionClient export const deleteAccountAction = actionClientUser .metadata({ name: "deleteAccount" }) - .action(async ({ ctx: { userId } }) => { - await clearLastEmailAccountCookie().catch(() => {}); + .action(async ({ ctx: { userId, logger } }) => { + await clearLastEmailAccountCookie().catch((error) => { + logger.error("Failed to clear last email account cookie", { error }); + }); await betterAuthConfig.api .signOut({ headers: await headers(), }) - .catch(() => {}); + .catch((error) => { + logger.error("Failed to sign out", { error }); + }); await deleteUser({ userId }); }); export const deleteEmailAccountAction = actionClientUser .metadata({ name: "deleteEmailAccount" }) - .schema(z.object({ emailAccountId: z.string() })) + .inputSchema(z.object({ emailAccountId: z.string() })) .action(async ({ ctx: { userId }, parsedInput: { emailAccountId } }) => { const emailAccount = await prisma.emailAccount.findUnique({ where: { id: emailAccountId, userId },