diff --git a/apps/web/.env.example b/apps/web/.env.example index 163c11b20b..b8361be673 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -22,6 +22,8 @@ ENCRYPT_SALT= # Set this to true if you haven't set `TINYBIRD_TOKEN`. # Some of the app's featues will be disabled when this is set. # NEXT_PUBLIC_DISABLE_TINYBIRD=true +# Generate a random secret here: https://generate-secret.vercel.app/32 +API_KEY_SALT= LOOPS_API_SECRET= diff --git a/apps/web/app/(app)/automation/group/Groups.tsx b/apps/web/app/(app)/automation/group/Groups.tsx index da4f3df384..8d1c7fd167 100644 --- a/apps/web/app/(app)/automation/group/Groups.tsx +++ b/apps/web/app/(app)/automation/group/Groups.tsx @@ -32,7 +32,7 @@ export function Groups() {
Groups - + Groups are used to group together emails that are related to each other. They can be created manually, or preset group can be generated for you automatically with AI. diff --git a/apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx b/apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx index a53fa3d822..a542391c5f 100644 --- a/apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx +++ b/apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx @@ -1,12 +1,10 @@ "use client"; -import Link from "next/link"; import useSWR from "swr"; import groupBy from "lodash/groupBy"; import { TopSection } from "@/components/TopSection"; -import { Button } from "@/components/ui/button"; import { ExampleList } from "@/app/(app)/automation/rule/[ruleId]/examples/example-list"; -import type { ExamplesResponse } from "@/app/api/user/rules/[id]/example/route"; +import type { GroupEmailsResponse } from "@/app/api/user/group/[groupId]/messages/controller"; import { LoadingContent } from "@/components/LoadingContent"; export const dynamic = "force-dynamic"; @@ -16,11 +14,11 @@ export default function RuleExamplesPage({ }: { params: { groupId: string }; }) { - const { data, isLoading, error } = useSWR( - `/api/user/group/${params.groupId}/examples`, + const { data, isLoading, error } = useSWR( + `/api/user/group/${params.groupId}/messages`, ); - const threads = groupBy(data, (m) => m.threadId); + const threads = groupBy(data?.messages, (m) => m.threadId); const groupedBySenders = groupBy(threads, (t) => t[0]?.headers.from); const hasExamples = Object.keys(groupedBySenders).length > 0; diff --git a/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx b/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx new file mode 100644 index 0000000000..a4b3604505 --- /dev/null +++ b/apps/web/app/(app)/settings/ApiKeysCreateForm.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { type SubmitHandler, useForm } from "react-hook-form"; +import { Button } from "@/components/Button"; +import { Button as UiButton } from "@/components/ui/button"; +import { Input } from "@/components/Input"; +import { isActionError } from "@/utils/error"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { createApiKeyBody, CreateApiKeyBody } from "@/utils/actions/validation"; +import { + createApiKeyAction, + deactivateApiKeyAction, +} from "@/utils/actions/api-key"; +import { handleActionResult } from "@/utils/server-action"; +import { toastError } from "@/components/Toast"; +import { CopyInput } from "@/components/CopyInput"; +import { SectionDescription } from "@/components/Typography"; + +export function ApiKeysCreateButtonModal() { + return ( + + + + + + + Create new secret key + + This will create a new secret key for your account. You will need to + use this secret key to authenticate your requests to the API. + + + + + + + ); +} + +function ApiKeysForm() { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(createApiKeyBody), + defaultValues: {}, + }); + + const [secretKey, setSecretKey] = useState(""); + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + const result = await createApiKeyAction(data); + handleActionResult(result, "API key created!"); + + if (!isActionError(result) && result?.secretKey) { + setSecretKey(result.secretKey); + } else { + toastError({ description: "Failed to create API key" }); + } + }, + [], + ); + + return !secretKey ? ( +
+ + + +
+ ) : ( +
+ + This will only be shown once. Please copy it. Your secret key is: + + +
+ ); +} + +export function ApiKeysDeactivateButton({ id }: { id: string }) { + return ( + { + const result = await deactivateApiKeyAction({ id }); + handleActionResult(result, "API key deactivated!"); + }} + > + Revoke + + ); +} diff --git a/apps/web/app/(app)/settings/ApiKeysSection.tsx b/apps/web/app/(app)/settings/ApiKeysSection.tsx new file mode 100644 index 0000000000..e048753fdc --- /dev/null +++ b/apps/web/app/(app)/settings/ApiKeysSection.tsx @@ -0,0 +1,69 @@ +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { FormSection, FormSectionLeft } from "@/components/Form"; +import prisma from "@/utils/prisma"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + ApiKeysCreateButtonModal, + ApiKeysDeactivateButton, +} from "@/app/(app)/settings/ApiKeysCreateForm"; +import { Card } from "@/components/ui/card"; + +export async function ApiKeysSection() { + const session = await auth(); + const userId = session?.user.id; + if (!userId) throw new Error("Not authenticated"); + + const apiKeys = await prisma.apiKey.findMany({ + where: { userId, isActive: true }, + select: { + id: true, + name: true, + createdAt: true, + }, + }); + + return ( + + + +
+ {apiKeys.length > 0 ? ( + + + + + Name + Created + + + + + {apiKeys.map((apiKey) => ( + + {apiKey.name} + {apiKey.createdAt.toLocaleString()} + + + + + ))} + +
+
+ ) : null} + + +
+
+ ); +} diff --git a/apps/web/app/(app)/settings/DeleteSection.tsx b/apps/web/app/(app)/settings/DeleteSection.tsx index 6edca2992d..75b2b269a0 100644 --- a/apps/web/app/(app)/settings/DeleteSection.tsx +++ b/apps/web/app/(app)/settings/DeleteSection.tsx @@ -2,7 +2,6 @@ import { Button } from "@/components/Button"; import { FormSection, FormSectionLeft } from "@/components/Form"; -import { toastError, toastSuccess } from "@/components/Toast"; import { deleteAccountAction } from "@/utils/actions/user"; import { handleActionResult } from "@/utils/server-action"; import { logOut } from "@/utils/user"; diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 902298f7eb..c96e6091fd 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -5,6 +5,7 @@ import { DeleteSection } from "@/app/(app)/settings/DeleteSection"; import { ModelSection } from "@/app/(app)/settings/ModelSection"; import { EmailUpdatesSection } from "@/app/(app)/settings/EmailUpdatesSection"; import { MultiAccountSection } from "@/app/(app)/settings/MultiAccountSection"; +import { ApiKeysSection } from "@/app/(app)/settings/ApiKeysSection"; export default function Settings() { return ( @@ -14,6 +15,7 @@ export default function Settings() { + ); diff --git a/apps/web/app/api/user/group/[groupId]/examples/route.ts b/apps/web/app/api/user/group/[groupId]/examples/route.ts deleted file mode 100644 index 3939efa7b0..0000000000 --- a/apps/web/app/api/user/group/[groupId]/examples/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; -import { getGmailClient } from "@/utils/gmail/client"; -import { fetchGroupExampleMessages } from "@/app/api/user/rules/[id]/example/controller"; - -export type ExamplesResponse = Awaited>; - -async function getExamples(options: { groupId: string }) { - const session = await auth(); - if (!session?.user.email) throw new Error("Not logged in"); - - const group = await prisma.group.findUnique({ - where: { id: options.groupId, userId: session.user.id }, - include: { items: true }, - }); - - if (!group) throw new Error("Rule not found"); - - const gmail = getGmailClient(session); - - const exampleMessages = await fetchGroupExampleMessages(group, gmail); - - return exampleMessages; -} - -export const GET = withError(async (_request, { params }) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const groupId = params.groupId; - if (!groupId) return NextResponse.json({ error: "Missing group id" }); - - const result = await getExamples({ groupId }); - - return NextResponse.json(result); -}); diff --git a/apps/web/app/api/user/group/[groupId]/messages/controller.ts b/apps/web/app/api/user/group/[groupId]/messages/controller.ts new file mode 100644 index 0000000000..71dc6422ab --- /dev/null +++ b/apps/web/app/api/user/group/[groupId]/messages/controller.ts @@ -0,0 +1,269 @@ +import prisma from "@/utils/prisma"; +import { gmail_v1 } from "googleapis"; +import { createHash } from "crypto"; +import groupBy from "lodash/groupBy"; +import { getMessage } from "@/utils/gmail/message"; +import { findMatchingGroupItem } from "@/utils/group/find-matching-group"; +import { parseMessage } from "@/utils/mail"; +import { extractEmailAddress } from "@/utils/email"; +import { GroupItem, GroupItemType } from "@prisma/client"; +import type { MessageWithGroupItem } from "@/app/(app)/automation/rule/[ruleId]/examples/types"; + +const PAGE_SIZE = 20; + +interface InternalPaginationState { + type: GroupItemType; + chunkIndex: number; + pageToken?: string; + groupItemsHash: string; +} + +export type GroupEmailsResponse = Awaited>; + +export async function getGroupEmails({ + groupId, + userId, + gmail, + from, + to, + pageToken, +}: { + groupId: string; + userId: string; + gmail: gmail_v1.Gmail; + from?: Date; + to?: Date; + pageToken?: string; +}) { + const group = await prisma.group.findUnique({ + where: { id: groupId, userId }, + include: { items: true }, + }); + + if (!group) throw new Error("Group not found"); + + const { messages, nextPageToken } = await fetchPaginatedMessages({ + groupItems: group.items, + gmail, + from, + to, + pageToken, + }); + + return { messages, nextPageToken }; +} + +export async function fetchPaginatedMessages({ + groupItems, + gmail, + from, + to, + pageToken, +}: { + groupItems: GroupItem[]; + gmail: gmail_v1.Gmail; + from?: Date; + to?: Date; + pageToken?: string; +}) { + const groupItemsHash = createGroupItemsHash(groupItems); + let paginationState: InternalPaginationState; + + const defaultPaginationState = { + type: GroupItemType.FROM, + chunkIndex: 0, + groupItemsHash, + }; + + if (pageToken) { + try { + const decodedState = JSON.parse( + Buffer.from(pageToken, "base64").toString("utf-8"), + ); + if (decodedState.groupItemsHash === groupItemsHash) { + paginationState = decodedState; + } else { + // Group items have changed, start from the beginning + paginationState = defaultPaginationState; + } + } catch (error) { + // Invalid pageToken, start from the beginning + paginationState = defaultPaginationState; + } + } else { + paginationState = defaultPaginationState; + } + + const { messages, nextPaginationState } = await fetchPaginatedGroupMessages( + groupItems, + gmail, + from, + to, + paginationState, + ); + + const nextPageToken = nextPaginationState + ? Buffer.from(JSON.stringify(nextPaginationState)).toString("base64") + : undefined; + + return { messages, nextPageToken }; +} + +// used for pagination +// if the group items change, we start from the beginning +function createGroupItemsHash( + groupItems: { type: string; value: string }[], +): string { + const itemsString = JSON.stringify( + groupItems.map((item) => ({ type: item.type, value: item.value })), + ); + return createHash("md5").update(itemsString).digest("hex"); +} + +// we set up our own pagination +// as we have to paginate through multiple types +// and for each type, through multiple chunks +async function fetchPaginatedGroupMessages( + groupItems: GroupItem[], + gmail: gmail_v1.Gmail, + from: Date | undefined, + to: Date | undefined, + paginationState: InternalPaginationState, +): Promise<{ + messages: MessageWithGroupItem[]; + nextPaginationState?: InternalPaginationState; +}> { + const CHUNK_SIZE = PAGE_SIZE; + + const groupItemTypes: GroupItemType[] = [ + GroupItemType.FROM, + GroupItemType.SUBJECT, + ]; + const groupItemsByType = groupBy(groupItems, (item) => item.type); + + let messages: MessageWithGroupItem[] = []; + let nextPaginationState: InternalPaginationState | undefined; + + const processChunk = async (type: GroupItemType) => { + const items = groupItemsByType[type] || []; + while (paginationState.type === type && messages.length < PAGE_SIZE) { + const chunk = items.slice( + paginationState.chunkIndex * CHUNK_SIZE, + (paginationState.chunkIndex + 1) * CHUNK_SIZE, + ); + if (chunk.length === 0) break; + + const result = await fetchGroupMessages( + type, + chunk, + gmail, + PAGE_SIZE - messages.length, + from, + to, + paginationState.pageToken, + ); + messages = [...messages, ...result.messages]; + + if (result.nextPageToken) { + nextPaginationState = { + type, + chunkIndex: paginationState.chunkIndex, + pageToken: result.nextPageToken, + groupItemsHash: paginationState.groupItemsHash, + }; + break; + } else { + paginationState.chunkIndex++; + paginationState.pageToken = undefined; + } + } + }; + + for (const type of groupItemTypes) { + if (messages.length < PAGE_SIZE) { + await processChunk(type); + } else { + break; + } + } + + // Handle transition to the next GroupItemType if current type is exhausted + // This ensures we paginate through all types in order + if (!nextPaginationState && messages.length < PAGE_SIZE) { + const nextTypeIndex = groupItemTypes.indexOf(paginationState.type) + 1; + if (nextTypeIndex < groupItemTypes.length) { + nextPaginationState = { + type: groupItemTypes[nextTypeIndex], + chunkIndex: 0, + groupItemsHash: paginationState.groupItemsHash, + }; + } + } + + return { messages, nextPaginationState }; +} + +async function fetchGroupMessages( + groupItemType: GroupItemType, + groupItems: GroupItem[], + gmail: gmail_v1.Gmail, + maxResults: number, + from?: Date, + to?: Date, + pageToken?: string, +): Promise<{ messages: MessageWithGroupItem[]; nextPageToken?: string }> { + const q = buildQuery(groupItemType, groupItems, from, to); + + const response = await gmail.users.messages.list({ + userId: "me", + maxResults, + pageToken, + q, + }); + + const messages = await Promise.all( + (response.data.messages || []).map(async (message) => { + const m = await getMessage(message.id!, gmail); + const parsedMessage = parseMessage(m); + const matchingGroupItem = findMatchingGroupItem( + parsedMessage.headers, + groupItems, + ); + return { ...parsedMessage, matchingGroupItem }; + }), + ); + + return { + // search might include messages that don't match the rule, so we filter those out + messages: messages.filter((message) => message.matchingGroupItem), + nextPageToken: response.data.nextPageToken || undefined, + }; +} + +function buildQuery( + groupItemType: GroupItemType, + groupItems: GroupItem[], + from?: Date, + to?: Date, +) { + const beforeQuery = from + ? `before:${Math.floor(from.getTime() / 1000)} ` + : ""; + const afterQuery = to ? `after:${Math.floor(to.getTime() / 1000)} ` : ""; + + if (groupItemType === GroupItemType.FROM) { + const q = `from:(${groupItems + .map((item) => `"${extractEmailAddress(item.value)}"`) + .join(" OR ")}) ${beforeQuery}${afterQuery}`; + return q; + } + + if (groupItemType === GroupItemType.SUBJECT) { + const q = `subject:(${groupItems + .map((item) => `"${item.value}"`) + .join(" OR ")}) ${beforeQuery}${afterQuery}`; + return q; + } + + return ""; +} diff --git a/apps/web/app/api/user/group/[groupId]/messages/route.ts b/apps/web/app/api/user/group/[groupId]/messages/route.ts new file mode 100644 index 0000000000..635cd07d56 --- /dev/null +++ b/apps/web/app/api/user/group/[groupId]/messages/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { withError } from "@/utils/middleware"; +import { getGroupEmails } from "@/app/api/user/group/[groupId]/messages/controller"; +import { getGmailClient } from "@/utils/gmail/client"; + +export const GET = withError(async (_request, { params }) => { + const session = await auth(); + if (!session?.user.email) + return NextResponse.json({ error: "Not authenticated" }); + + const groupId = params.groupId; + if (!groupId) return NextResponse.json({ error: "Missing group id" }); + + const gmail = getGmailClient(session); + + const { messages } = await getGroupEmails({ + groupId, + userId: session.user.id, + gmail, + from: undefined, + to: undefined, + pageToken: "", + }); + + return NextResponse.json({ messages }); +}); 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 f1d659b0e1..bd54666a6e 100644 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ b/apps/web/app/api/user/rules/[id]/example/controller.ts @@ -1,5 +1,5 @@ import type { gmail_v1 } from "googleapis"; -import { GroupItemType, RuleType } from "@prisma/client"; +import { RuleType } from "@prisma/client"; import { parseMessage } from "@/utils/mail"; import { getMessage } from "@/utils/gmail/message"; import type { @@ -7,8 +7,7 @@ import type { RuleWithGroup, } from "@/app/(app)/automation/rule/[ruleId]/examples/types"; import { matchesStaticRule } from "@/app/api/google/webhook/static-rule"; -import { extractEmailAddress } from "@/utils/email"; -import { findMatchingGroupItem } from "@/utils/group/find-matching-group"; +import { fetchPaginatedMessages } from "@/app/api/user/group/[groupId]/messages/controller"; export async function fetchExampleMessages( rule: RuleWithGroup, @@ -19,7 +18,11 @@ export async function fetchExampleMessages( return fetchStaticExampleMessages(rule, gmail); case RuleType.GROUP: if (!rule.group) return []; - return fetchGroupExampleMessages(rule.group, gmail); + const { messages } = await fetchPaginatedMessages({ + groupItems: rule.group.items, + gmail, + }); + return messages; case RuleType.AI: return []; } @@ -57,75 +60,3 @@ async function fetchStaticExampleMessages( // search might include messages that don't match the rule, so we filter those out return messages.filter((message) => matchesStaticRule(rule, message)); } - -export async function fetchGroupExampleMessages( - group: NonNullable, - gmail: gmail_v1.Gmail, -): Promise { - const items = group.items || []; - - let responseMessages: gmail_v1.Schema$Message[] = []; - - // we slice to avoid the query being too long or it won't work - const froms = items - .filter((item) => item.type === GroupItemType.FROM) - .slice(0, 50); - - if (froms.length > 0) { - const q = `from:(${froms - .map((item) => `"${extractEmailAddress(item.value)}"`) - .join(" OR ")}) `; - - const responseFrom = await gmail.users.messages.list({ - userId: "me", - maxResults: 50, - q, - }); - - if (responseFrom.data.messages) - responseMessages = responseFrom.data.messages; - } - - const subjects = items - .filter((item) => item.type === GroupItemType.SUBJECT) - .slice(0, 50); - - if (subjects.length > 0) { - const q = `subject:(${subjects - .map((item) => `"${item.value}"`) - .join(" OR ")})`; - - const responseSubject = await gmail.users.messages.list({ - userId: "me", - maxResults: 50, - q, - }); - - if (responseSubject.data.messages) { - responseMessages = [ - ...responseMessages, - ...responseSubject.data.messages, - ]; - } - } - - const messages = await Promise.all( - responseMessages.map(async (message) => { - const m = await getMessage(message.id!, gmail); - const parsedMessage = parseMessage(m); - - const matchingGroupItem = findMatchingGroupItem( - parsedMessage.headers, - group.items, - ); - - return { - ...parsedMessage, - matchingGroupItem, - }; - }), - ); - - // search might include messages that don't match the rule, so we filter those out - return messages.filter((message) => message.matchingGroupItem); -} diff --git a/apps/web/app/api/v1/group/[groupId]/emails/route.ts b/apps/web/app/api/v1/group/[groupId]/emails/route.ts new file mode 100644 index 0000000000..349ce95986 --- /dev/null +++ b/apps/web/app/api/v1/group/[groupId]/emails/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { getGroupEmails } from "@/app/api/user/group/[groupId]/messages/controller"; +import { getGmailClientWithRefresh } from "@/utils/gmail/client"; +import { hashApiKey } from "@/utils/api-key"; +import { + groupEmailsQuerySchema, + GroupEmailsResult, +} from "@/app/api/v1/group/[groupId]/emails/validation"; + +export async function GET( + request: NextRequest, + { params }: { params: { groupId: string } }, +) { + const { groupId } = params; + const { searchParams } = new URL(request.url); + const queryResult = groupEmailsQuerySchema.safeParse( + Object.fromEntries(searchParams), + ); + + if (!queryResult.success) { + return NextResponse.json( + { error: "Invalid query parameters" }, + { status: 400 }, + ); + } + + const apiKey = request.headers.get("API-Key"); + + if (!apiKey) + return NextResponse.json({ error: "Missing API key" }, { status: 401 }); + + const user = await getUserFromApiKey(apiKey); + + if (!user) + return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); + + const account = user.accounts[0]; + + if (!account) + return NextResponse.json({ error: "Missing account" }, { status: 401 }); + + if (!account.access_token || !account.refresh_token || !account.expires_at) + return NextResponse.json( + { error: "Missing access token" }, + { status: 401 }, + ); + + const gmail = await getGmailClientWithRefresh( + { + accessToken: account.access_token, + refreshToken: account.refresh_token, + expiryDate: account.expires_at, + }, + account.providerAccountId, + ); + + const { pageToken, from, to } = queryResult.data; + + const { messages, nextPageToken } = await getGroupEmails({ + groupId, + userId: user.id, + gmail, + from: from ? new Date(from) : undefined, + to: to ? new Date(to) : undefined, + pageToken, + }); + + const result: GroupEmailsResult = { + messages, + nextPageToken, + }; + + return NextResponse.json(result); +} + +async function getUserFromApiKey(secretKey: string) { + const hashedKey = hashApiKey(secretKey); + + const result = await prisma.apiKey.findUnique({ + where: { hashedKey, isActive: true }, + select: { + user: { + select: { + id: true, + accounts: { + select: { + access_token: true, + refresh_token: true, + expires_at: true, + providerAccountId: true, + }, + where: { provider: "google" }, + take: 1, + }, + }, + }, + isActive: true, + }, + }); + + return result?.user; +} diff --git a/apps/web/app/api/v1/group/[groupId]/emails/validation.ts b/apps/web/app/api/v1/group/[groupId]/emails/validation.ts new file mode 100644 index 0000000000..914e87ce85 --- /dev/null +++ b/apps/web/app/api/v1/group/[groupId]/emails/validation.ts @@ -0,0 +1,38 @@ +import { GroupItemType } from "@prisma/client"; +import { z } from "zod"; + +export const groupEmailsQuerySchema = z.object({ + pageToken: z.string().optional(), + from: z.coerce.number().optional(), + to: z.coerce.number().optional(), +}); + +export const groupEmailsResponseSchema = z.object({ + messages: z.array( + z.object({ + id: z.string(), + threadId: z.string(), + labelIds: z.array(z.string()).optional(), + snippet: z.string(), + historyId: z.string(), + attachments: z.array(z.object({})).optional(), + inline: z.array(z.object({})), + headers: z.object({}), + textPlain: z.string().optional(), + textHtml: z.string().optional(), + matchingGroupItem: z + .object({ + id: z.string(), + type: z.enum([ + GroupItemType.FROM, + GroupItemType.SUBJECT, + GroupItemType.BODY, + ]), + value: z.string(), + }) + .nullish(), + }), + ), + nextPageToken: z.string().optional(), +}); +export type GroupEmailsResult = z.infer; diff --git a/apps/web/app/api/v1/openapi/route.ts b/apps/web/app/api/v1/openapi/route.ts new file mode 100644 index 0000000000..ce44a2927a --- /dev/null +++ b/apps/web/app/api/v1/openapi/route.ts @@ -0,0 +1,71 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import { + OpenAPIRegistry, + extendZodWithOpenApi, +} from "@asteasolutions/zod-to-openapi"; +import { + groupEmailsQuerySchema, + groupEmailsResponseSchema, +} from "@/app/api/v1/group/[groupId]/emails/validation"; + +extendZodWithOpenApi(z); + +const registry = new OpenAPIRegistry(); + +registry.registerComponent("securitySchemes", "ApiKeyAuth", { + type: "apiKey", + in: "header", + name: "API-Key", +}); + +registry.registerPath({ + method: "get", + path: "/group/{groupId}/emails", + description: "Get group emails", + security: [{ ApiKeyAuth: [] }], + request: { + params: z.object({ groupId: z.string() }), + query: groupEmailsQuerySchema, + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: groupEmailsResponseSchema, + }, + }, + }, + }, +}); + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const customHost = searchParams.get("host"); + + const generator = new OpenApiGeneratorV3(registry.definitions); + const docs = generator.generateDocument({ + openapi: "3.1.0", + info: { + title: "Inbox Zero API", + version: "1.0.0", + }, + servers: [ + ...(customHost + ? [{ url: `${customHost}/api/v1`, description: "Custom host" }] + : []), + { + url: "https://getinboxzero.com/api/v1", + description: "Production server", + }, + { url: "http://localhost:3000/api/v1", description: "Local development" }, + ], + security: [{ ApiKeyAuth: [] }], + }); + + return new NextResponse(JSON.stringify(docs), { + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/apps/web/components/CopyInput.tsx b/apps/web/components/CopyInput.tsx new file mode 100644 index 0000000000..25312420d4 --- /dev/null +++ b/apps/web/components/CopyInput.tsx @@ -0,0 +1,30 @@ +import { CopyIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function CopyInput({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + return ( +
+ + +
+ ); +} diff --git a/apps/web/env.ts b/apps/web/env.ts index 00d7b1d347..c7d19e180c 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -26,6 +26,7 @@ export const env = createEnv({ TINYBIRD_BASE_URL: z.string().default("https://api.us-east.tinybird.co/"), ENCRYPT_SECRET: z.string().optional(), ENCRYPT_SALT: z.string().optional(), + API_KEY_SALT: z.string().optional(), POSTHOG_API_SECRET: z.string().optional(), POSTHOG_PROJECT_ID: z.string().optional(), RESEND_API_KEY: z.string().optional(), diff --git a/apps/web/package.json b/apps/web/package.json index ddec436a0d..538d3aff61 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@ai-sdk/anthropic": "^0.0.33", "@ai-sdk/openai": "^0.0.40", + "@asteasolutions/zod-to-openapi": "^7.1.1", "@auth/core": "^0.34.2", "@auth/prisma-adapter": "^2.4.2", "@formkit/auto-animate": "^0.8.2", @@ -107,6 +108,7 @@ }, "devDependencies": { "@headlessui/tailwindcss": "^0.2.1", + "@inboxzero/eslint-config": "workspace:*", "@testing-library/react": "^16.0.0", "@types/he": "^1.2.3", "@types/html-to-text": "^9.0.4", @@ -117,7 +119,6 @@ "@types/react-dom": "18.3.0", "autoprefixer": "10.4.19", "dotenv": "^16.4.5", - "@inboxzero/eslint-config": "workspace:*", "jiti": "^1.21.6", "jsdom": "^24.1.1", "postcss": "8.4.40", diff --git a/apps/web/prisma/migrations/20240728084326_api_key/migration.sql b/apps/web/prisma/migrations/20240728084326_api_key/migration.sql new file mode 100644 index 0000000000..061002043d --- /dev/null +++ b/apps/web/prisma/migrations/20240728084326_api_key/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "name" TEXT, + "hashedKey" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "userId" TEXT NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); + +-- CreateIndex +CREATE INDEX "ApiKey_userId_isActive_idx" ON "ApiKey"("userId", "isActive"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 8c077f6f83..199856d632 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -85,6 +85,7 @@ model User { newsletters Newsletter[] coldEmails ColdEmail[] groups Group[] + apiKeys ApiKey[] } model Premium { @@ -311,6 +312,20 @@ model ColdEmail { @@index([userId, status]) } +model ApiKey { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String? + hashedKey String @unique + isActive Boolean @default(true) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, isActive]) +} + enum ActionType { ARCHIVE LABEL diff --git a/apps/web/utils/actions/api-key.ts b/apps/web/utils/actions/api-key.ts new file mode 100644 index 0000000000..1090ec8d85 --- /dev/null +++ b/apps/web/utils/actions/api-key.ts @@ -0,0 +1,64 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/app/api/auth/[...nextauth]/auth"; +import { + createApiKeyBody, + deactivateApiKeyBody, +} from "@/utils/actions/validation"; +import type { + CreateApiKeyBody, + DeactivateApiKeyBody, +} from "@/utils/actions/validation"; +import { ServerActionResponse } from "@/utils/error"; +import prisma from "@/utils/prisma"; +import { generateSecureApiKey, hashApiKey } from "@/utils/api-key"; + +export async function createApiKeyAction( + unsafeData: CreateApiKeyBody, +): Promise> { + const session = await auth(); + const userId = session?.user.id; + if (!userId) return { error: "Not logged in" }; + + const data = createApiKeyBody.safeParse(unsafeData); + if (!data.success) return { error: "Invalid data" }; + + console.log(`Creating API key for ${userId}`); + + const secretKey = generateSecureApiKey(); + const hashedKey = hashApiKey(secretKey); + + await prisma.apiKey.create({ + data: { + userId, + name: data.data.name || "Secret key", + hashedKey, + isActive: true, + }, + }); + + revalidatePath("/settings"); + + return { secretKey }; +} + +export async function deactivateApiKeyAction( + unsafeData: DeactivateApiKeyBody, +): Promise { + const session = await auth(); + const userId = session?.user.id; + if (!userId) return { error: "Not logged in" }; + + const data = deactivateApiKeyBody.safeParse(unsafeData); + if (!data.success) return { error: "Invalid data" }; + + console.log(`Deactivating API key for ${userId}`); + + await prisma.apiKey.update({ + where: { id: data.data.id, userId }, + data: { isActive: false }, + }); + + revalidatePath("/settings"); +} diff --git a/apps/web/utils/actions/validation.ts b/apps/web/utils/actions/validation.ts index d3bd221441..97ea6ec7c6 100644 --- a/apps/web/utils/actions/validation.ts +++ b/apps/web/utils/actions/validation.ts @@ -73,3 +73,10 @@ export const updateRuleBody = createRuleBody.extend({ actions: z.array(zodAction.extend({ id: z.string().optional() })), }); export type UpdateRuleBody = z.infer; + +// api key +export const createApiKeyBody = z.object({ name: z.string().nullish() }); +export type CreateApiKeyBody = z.infer; + +export const deactivateApiKeyBody = z.object({ id: z.string() }); +export type DeactivateApiKeyBody = z.infer; diff --git a/apps/web/utils/api-key.ts b/apps/web/utils/api-key.ts new file mode 100644 index 0000000000..22385a147c --- /dev/null +++ b/apps/web/utils/api-key.ts @@ -0,0 +1,12 @@ +import { env } from "@/env"; +import { randomBytes, scryptSync } from "node:crypto"; + +export function generateSecureApiKey(): string { + return randomBytes(32).toString("base64"); +} + +export function hashApiKey(apiKey: string): string { + if (!env.API_KEY_SALT) throw new Error("API_KEY_SALT is not set"); + const derivedKey = scryptSync(apiKey, env.API_KEY_SALT, 64); + return `${env.API_KEY_SALT}:${derivedKey.toString("hex")}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 448bf1def9..7d58073d75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@ai-sdk/openai': specifier: ^0.0.40 version: 0.0.40(zod@3.23.8) + '@asteasolutions/zod-to-openapi': + specifier: ^7.1.1 + version: 7.1.1(zod@3.23.8) '@auth/core': specifier: ^0.34.2 version: 0.34.2(nodemailer@6.9.14) @@ -587,6 +590,11 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asteasolutions/zod-to-openapi@7.1.1': + resolution: {integrity: sha512-lF0d1gAc0lYLO9/BAGivwTwE2Sh9h6CHuDcbk5KnGBfIuAsAkDC+Fdat4dkQY3CS/zUWKHRmFEma0B7X132Ymw==} + peerDependencies: + zod: ^3.20.2 + '@auth/core@0.32.0': resolution: {integrity: sha512-3+ssTScBd+1fd0/fscAyQN1tSygXzuhysuVVzB942ggU4mdfiTbv36P0ccVnExKWYJKvu3E2r3/zxXCCAmTOrg==} peerDependencies: @@ -6518,6 +6526,9 @@ packages: resolution: {integrity: sha512-ohYEv6OV3jsFGqNrgolDDWN6Ssx1nFg6JDJQuaBFo4SL2i+MBoOQ16n2Pq1iBF5lH1PKnfCIOfqAGkmzPvdB9g==} hasBin: true + openapi3-ts@4.3.3: + resolution: {integrity: sha512-LKkzBGJcZ6wdvkKGMoSvpK+0cbN5Xc3XuYkJskO+vjEQWJgs1kgtyUk0pjf8KwPuysv323Er62F5P17XQl96Qg==} + opentelemetry-instrumentation-fetch-node@1.2.3: resolution: {integrity: sha512-Qb11T7KvoCevMaSeuamcLsAD+pZnavkhDnlVL0kRozfhl42dKG5Q3anUklAFKJZjY3twLR+BnRa6DlwwkIE/+A==} engines: {node: '>18.0.0'} @@ -8481,6 +8492,11 @@ packages: resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} engines: {node: '>= 14'} + yaml@2.5.0: + resolution: {integrity: sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==} + engines: {node: '>= 14'} + hasBin: true + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -8613,6 +8629,11 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@asteasolutions/zod-to-openapi@7.1.1(zod@3.23.8)': + dependencies: + openapi3-ts: 4.3.3 + zod: 3.23.8 + '@auth/core@0.32.0(nodemailer@6.9.14)': dependencies: '@panva/hkdf': 1.1.1 @@ -9157,7 +9178,7 @@ snapshots: dependencies: '@mdx-js/mdx': 3.0.0 source-map: 0.7.4 - webpack: 5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11) + webpack: 5.90.3(@swc/core@1.6.5) transitivePeerDependencies: - supports-color @@ -11001,7 +11022,7 @@ snapshots: rollup: 3.29.4 stacktrace-parser: 0.1.10 optionalDependencies: - webpack: 5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11) + webpack: 5.90.3(@swc/core@1.6.5) transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/core' @@ -11083,7 +11104,7 @@ snapshots: '@sentry/bundler-plugin-core': 2.20.1(encoding@0.1.13) unplugin: 1.0.1 uuid: 9.0.0 - webpack: 5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11) + webpack: 5.90.3(@swc/core@1.6.5) transitivePeerDependencies: - encoding - supports-color @@ -11125,7 +11146,7 @@ snapshots: zod: 3.23.8 optionalDependencies: typescript: 5.5.4 - webpack: 5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11) + webpack: 5.90.3(@swc/core@1.6.5) '@serwist/window@9.0.5(typescript@5.5.4)': dependencies: @@ -13282,7 +13303,7 @@ snapshots: eslint: 9.8.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.8.0))(eslint@9.8.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@9.8.0) eslint-plugin-react: 7.35.0(eslint@9.8.0) eslint-plugin-react-hooks: 4.6.0(eslint@9.8.0) @@ -13328,7 +13349,7 @@ snapshots: enhanced-resolve: 5.15.0 eslint: 9.8.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -13384,7 +13405,7 @@ snapshots: eslint: 9.8.0 ignore: 5.2.4 - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.1.1(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.3 @@ -15802,6 +15823,10 @@ snapshots: transitivePeerDependencies: - encoding + openapi3-ts@4.3.3: + dependencies: + yaml: 2.5.0 + opentelemetry-instrumentation-fetch-node@1.2.3(@opentelemetry/api@1.9.0): dependencies: '@opentelemetry/api': 1.9.0 @@ -17338,7 +17363,7 @@ snapshots: tapable@2.2.1: {} - terser-webpack-plugin@5.3.10(@swc/core@1.6.5)(webpack@5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11)): + terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11)(webpack@5.90.3(@swc/core@1.6.5)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 @@ -17346,6 +17371,18 @@ snapshots: serialize-javascript: 6.0.2 terser: 5.26.0 webpack: 5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11) + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.5) + esbuild: 0.19.11 + + terser-webpack-plugin@5.3.10(@swc/core@1.6.5)(webpack@5.90.3(@swc/core@1.6.5)): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.26.0 + webpack: 5.90.3(@swc/core@1.6.5) optionalDependencies: '@swc/core': 1.6.5 @@ -18021,7 +18058,38 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.6.5)(webpack@5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11)) + terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11)(webpack@5.90.3(@swc/core@1.6.5)) + watchpack: 2.4.0 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + webpack@5.90.3(@swc/core@1.6.5): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.5 + '@webassemblyjs/ast': 1.11.6 + '@webassemblyjs/wasm-edit': 1.11.6 + '@webassemblyjs/wasm-parser': 1.11.6 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.23.0 + chrome-trace-event: 1.0.3 + enhanced-resolve: 5.15.0 + es-module-lexer: 1.4.1 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(@swc/core@1.6.5)(webpack@5.90.3(@swc/core@1.6.5)) watchpack: 2.4.0 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -18151,6 +18219,8 @@ snapshots: yaml@2.3.4: {} + yaml@2.5.0: {} + yn@3.1.1: {} yocto-queue@0.1.0: {}