Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions apps/web/app/(app)/[emailAccountId]/mail/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,22 @@ 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,
previousPageData: ThreadsResponse | null,
) => {
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;
Expand Down
84 changes: 46 additions & 38 deletions apps/web/app/(app)/admin/AdminHashEmail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,45 +52,52 @@ export const AdminHashEmail = () => {
};

return (
<form className="max-w-sm space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<h3 className="text-lg font-medium">Hash for Log Search</h3>
</div>
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Hash for Log Search</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<Input
type="text"
name="email"
label="Value to Hash"
placeholder="user@example.com"
registerProps={register("email")}
error={errors.email}
/>

<Input
type="text"
name="email"
label="Value to Hash"
placeholder="user@example.com"
registerProps={register("email")}
error={errors.email}
/>
<Button type="submit" loading={isExecuting}>
Generate Hash
</Button>

<Button type="submit" loading={isExecuting}>
Generate Hash
</Button>

{result.data?.hash && (
<div className="flex gap-2">
<div className="flex-1">
<Input
type="text"
name="hashedValue"
label="Hashed Value"
registerProps={{
value: result.data.hash,
readOnly: true,
}}
className="font-mono text-xs"
/>
</div>
<div className="flex items-end">
<Button type="button" variant="outline" onClick={copyToClipboard}>
Copy
</Button>
</div>
</div>
)}
</form>
{result.data?.hash && (
<div className="flex gap-2">
<div className="flex-1">
<Input
type="text"
name="hashedValue"
label="Hashed Value"
registerProps={{
value: result.data.hash,
readOnly: true,
}}
className="font-mono text-xs"
/>
</div>
<div className="flex items-end">
<Button
type="button"
variant="outline"
onClick={copyToClipboard}
>
Copy
</Button>
</div>
</div>
)}
</form>
</CardContent>
</Card>
);
};
103 changes: 103 additions & 0 deletions apps/web/app/(app)/admin/GmailUrlConverter.tsx
Original file line number Diff line number Diff line change
@@ -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<ConvertGmailUrlBody>({
resolver: zodResolver(convertGmailUrlBody),
});

const onSubmit: SubmitHandler<ConvertGmailUrlBody> = useCallback(
(data) => {
convertUrl(data);
},
[convertUrl],
);

return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>Email Message Lookup</CardTitle>
<CardDescription>
Find thread/message IDs using RFC822 Message-ID from email headers
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<Input
type="text"
name="rfc822MessageId"
label="RFC822 Message-ID"
placeholder="<abc123@email.example.com>"
registerProps={register("rfc822MessageId")}
error={errors.rfc822MessageId}
/>
<Input
type="email"
name="email"
label="Email Address"
placeholder="user@example.com"
registerProps={register("email")}
error={errors.email}
/>
<Button type="submit" loading={isExecuting}>
Lookup
</Button>
</form>

{result.data && (
<div className="space-y-2">
<div>
<span className="text-sm font-medium">Thread ID: </span>
<code className="text-sm">{result.data.threadId}</code>
</div>
<div>
<span className="text-sm font-medium">Message IDs: </span>
<code className="text-sm">
{result.data.messageIds.join(", ")}
</code>
</div>
</div>
)}
</CardContent>
</Card>
);
}
2 changes: 2 additions & 0 deletions apps/web/app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +35,7 @@ export default async function AdminPage() {
<AdminUpgradeUserForm />
<AdminUserControls />
<AdminHashEmail />
<GmailUrlConverter />
<RegisterSSOModal />

<div className="flex gap-2">
Expand Down
8 changes: 8 additions & 0 deletions apps/web/components/NavUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
PaletteIcon,
ChromeIcon,
Building2Icon,
CrownIcon,
} from "lucide-react";
import {
DropdownMenu,
Expand Down Expand Up @@ -174,6 +175,13 @@ export function NavUser() {
Usage
</Link>
</DropdownMenuItem>

<DropdownMenuItem asChild>
<Link href="/premium">
<CrownIcon className="mr-2 size-4" />
Premium
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>

<DropdownMenuSeparator />
Expand Down
7 changes: 0 additions & 7 deletions apps/web/components/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,6 @@ export function SideNav({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Link>
</SidebarMenuButton>

<SidebarMenuButton asChild>
<Link href="/premium">
<CrownIcon className="size-4" />
<span className="font-semibold">Premium</span>
</Link>
</SidebarMenuButton>

<SidebarMenuButton asChild>
<Link href={prefixPath(currentEmailAccountId, "/settings")}>
<SettingsIcon className="size-4" />
Expand Down
2 changes: 2 additions & 0 deletions apps/web/utils/__mocks__/email-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 60 additions & 1 deletion apps/web/utils/actions/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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,
};
});
6 changes: 6 additions & 0 deletions apps/web/utils/actions/admin.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ export const hashEmailBody = z.object({
email: z.string().min(1, "Value is required"),
});
export type HashEmailBody = z.infer<typeof hashEmailBody>;

export const convertGmailUrlBody = z.object({
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<typeof convertGmailUrlBody>;
4 changes: 2 additions & 2 deletions apps/web/utils/actions/email-account-cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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();
});
Loading
Loading