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
+
+
+
+ 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 (
+
+
+ {
+ navigator.clipboard.writeText(value);
+ setCopied(true);
+ }}
+ >
+
+ {copied ? "Copied!" : "Copy"}
+
+
+ );
+}
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: {}