From 1a0ad907096e73a012343f486610cfaaf0ee1942 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Sat, 19 Apr 2025 01:28:44 +0100
Subject: [PATCH 01/36] draft fixes: - added cc and bcc when saving drafts -
save drafts less aggresively
---
apps/mail/components/create/create-email.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/mail/components/create/create-email.tsx b/apps/mail/components/create/create-email.tsx
index 753a586dce..624ac687c8 100644
--- a/apps/mail/components/create/create-email.tsx
+++ b/apps/mail/components/create/create-email.tsx
@@ -255,12 +255,14 @@ export function CreateEmail({
const saveDraft = React.useCallback(async () => {
if (!hasUnsavedChanges) return;
- if (!toEmails.length && !subjectInput && !messageContent) return;
+ if (!toEmails.length || !subjectInput || !messageContent) return;
try {
setIsLoading(true);
const draftData = {
to: toEmails.join(', '),
+ cc: ccEmails.join(', '),
+ bcc: bccEmails.join(', '),
subject: subjectInput,
message: messageContent || '',
attachments: attachments,
From 1622ccabb8450b2a8053bee79660c2f97e9ce935 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Sat, 19 Apr 2025 02:29:07 +0100
Subject: [PATCH 02/36] some fixes for saving attachments to draft
---
apps/mail/app/api/driver/google.ts | 49 ++++++++++++++++++++++++++++--
1 file changed, 46 insertions(+), 3 deletions(-)
diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts
index 73d39d00cb..4215a55fbb 100644
--- a/apps/mail/app/api/driver/google.ts
+++ b/apps/mail/app/api/driver/google.ts
@@ -824,14 +824,57 @@ export const driver = async (config: IConfig): Promise => {
}
},
createDraft: async (data: any) => {
- const mimeMessage = [
+ // Generate a unique boundary for multipart messages
+ const boundary = `boundary_${Date.now()}`;
+
+ // Start building MIME message parts
+ const messageParts = [
`From: me`,
`To: ${data.to}`,
+ data.cc ? `Cc: ${data.cc}` : '',
+ data.bcc ? `Bcc: ${data.bcc}` : '',
`Subject: ${data.subject}`,
+ `MIME-Version: 1.0`,
+ `Content-Type: multipart/mixed; boundary=${boundary}`,
+ '',
+ `--${boundary}`,
'Content-Type: text/html; charset=utf-8',
'',
- data.message,
- ].join('\n');
+ data.message || '',
+ ];
+
+ // Add attachments if present
+ if (data.attachments?.length > 0) {
+ for (const attachment of data.attachments) {
+ const base64Data = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const base64 = (reader.result as string).split(',')[1];
+ if (base64) {
+ resolve(base64);
+ } else {
+ reject(new Error('Failed to read file as base64'));
+ }
+ };
+ reader.onerror = () => reject(new Error('Failed to read file'));
+ reader.readAsDataURL(attachment);
+ });
+
+ messageParts.push(
+ `--${boundary}`,
+ `Content-Type: ${attachment.type}`,
+ `Content-Transfer-Encoding: base64`,
+ `Content-Disposition: attachment; filename="${attachment.name}"`,
+ '',
+ base64Data,
+ );
+ }
+ }
+
+ // Close the multipart message
+ messageParts.push(`--${boundary}--`);
+
+ const mimeMessage = messageParts.filter(Boolean).join('\n');
const encodedMessage = Buffer.from(mimeMessage)
.toString('base64')
From eea882722f492a572a814e60b70bab2f03a62d8f Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Sat, 19 Apr 2025 02:30:52 +0100
Subject: [PATCH 03/36] fix for empty draft loading
---
apps/mail/components/create/create-email.tsx | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/apps/mail/components/create/create-email.tsx b/apps/mail/components/create/create-email.tsx
index 624ac687c8..ee0df71062 100644
--- a/apps/mail/components/create/create-email.tsx
+++ b/apps/mail/components/create/create-email.tsx
@@ -173,12 +173,24 @@ export function CreateEmail({
if (draft.content) {
try {
- const json = generateJSON(draft.content, [Document, Paragraph, Text, Bold]);
- setDefaultValue(json);
setMessageContent(draft.content);
+ setResetEditorKey((prev) => prev + 1);
+ setTimeout(() => {
+ try {
+ const json = generateJSON(draft.content, [Document, Paragraph, Text, Bold]);
+ setDefaultValue(json);
+ } catch (error) {
+ console.error('Error parsing draft content:', error);
+ setDefaultValue(createEmptyDocContent());
+ }
+ }, 0);
} catch (error) {
- console.error('Error parsing draft content:', error);
+ console.error('Error setting draft content:', error);
+ setDefaultValue(createEmptyDocContent());
}
+ } else {
+ setDefaultValue(createEmptyDocContent());
+ setMessageContent('');
}
setHasUnsavedChanges(false);
From 3a2860eaaa33d7532a0d22f3e429363b79e98414 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Sat, 19 Apr 2025 13:48:03 +0100
Subject: [PATCH 04/36] fix draft list recipient name/address
---
apps/mail/components/draft/drafts-list.tsx | 22 ++++++++++++++++++++--
1 file changed, 20 insertions(+), 2 deletions(-)
diff --git a/apps/mail/components/draft/drafts-list.tsx b/apps/mail/components/draft/drafts-list.tsx
index a659058218..48a1a54136 100644
--- a/apps/mail/components/draft/drafts-list.tsx
+++ b/apps/mail/components/draft/drafts-list.tsx
@@ -19,7 +19,13 @@ import { ChevronDown } from 'lucide-react';
import { Button } from '../ui/button';
import { toast } from 'sonner';
-const Draft = ({ message, onClick }: ThreadProps) => {
+import { ParsedMessage } from '@/types';
+
+interface DraftProps extends Omit {
+ message: ParsedMessage;
+}
+
+const Draft = ({ message, onClick }: DraftProps) => {
const [mail] = useMail();
const [searchValue] = useSearchValue();
@@ -51,7 +57,19 @@ const Draft = ({ message, onClick }: ThreadProps) => {
)}
>
- {highlightText(message.sender.name, searchValue.highlight)}
+ {message.to.some(
+ (to: { name: string; email: string }) =>
+ to.name.includes('no-sender') || to.email.includes('no-sender'),
+ )
+ ? 'No recipient'
+ : highlightText(
+ message.to
+ .map((to: { name: string; email: string }) =>
+ to.name === 'No Sender Name' ? to.email : `${to.name} <${to.email}>`,
+ )
+ .join(', '),
+ searchValue.highlight,
+ )}
From 397090cab97ecc7f3e81c70972775501c113d4c2 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Sat, 19 Apr 2025 13:49:47 +0100
Subject: [PATCH 05/36] also show 'No Recipient' if empty
---
apps/mail/components/draft/drafts-list.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/mail/components/draft/drafts-list.tsx b/apps/mail/components/draft/drafts-list.tsx
index 48a1a54136..89c84332ed 100644
--- a/apps/mail/components/draft/drafts-list.tsx
+++ b/apps/mail/components/draft/drafts-list.tsx
@@ -57,7 +57,8 @@ const Draft = ({ message, onClick }: DraftProps) => {
)}
>
- {message.to.some(
+ {!message.to?.length ||
+ message.to.some(
(to: { name: string; email: string }) =>
to.name.includes('no-sender') || to.email.includes('no-sender'),
)
From cc52f7362d30929bd482dad974689f72fc770623 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Sat, 19 Apr 2025 14:37:00 +0100
Subject: [PATCH 06/36] remove comments
---
apps/mail/app/api/driver/google.ts | 4 ----
1 file changed, 4 deletions(-)
diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts
index 4215a55fbb..a5166390dc 100644
--- a/apps/mail/app/api/driver/google.ts
+++ b/apps/mail/app/api/driver/google.ts
@@ -824,10 +824,8 @@ export const driver = async (config: IConfig): Promise => {
}
},
createDraft: async (data: any) => {
- // Generate a unique boundary for multipart messages
const boundary = `boundary_${Date.now()}`;
- // Start building MIME message parts
const messageParts = [
`From: me`,
`To: ${data.to}`,
@@ -843,7 +841,6 @@ export const driver = async (config: IConfig): Promise => {
data.message || '',
];
- // Add attachments if present
if (data.attachments?.length > 0) {
for (const attachment of data.attachments) {
const base64Data = await new Promise((resolve, reject) => {
@@ -871,7 +868,6 @@ export const driver = async (config: IConfig): Promise => {
}
}
- // Close the multipart message
messageParts.push(`--${boundary}--`);
const mimeMessage = messageParts.filter(Boolean).join('\n');
From 0214edbe552e3c625bbd17eddfe69f8097f74a15 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Mon, 21 Apr 2025 02:19:46 +0100
Subject: [PATCH 07/36] switch to mimetext for draft saving to keep formatting
consistent
---
apps/mail/app/api/driver/google.ts | 46 ++++++++++++------------------
1 file changed, 18 insertions(+), 28 deletions(-)
diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts
index 4902777eaf..60842fd8a7 100644
--- a/apps/mail/app/api/driver/google.ts
+++ b/apps/mail/app/api/driver/google.ts
@@ -828,22 +828,18 @@ export const driver = async (config: IConfig): Promise => {
}
},
createDraft: async (data: any) => {
- const boundary = `boundary_${Date.now()}`;
-
- const messageParts = [
- `From: me`,
- `To: ${data.to}`,
- data.cc ? `Cc: ${data.cc}` : '',
- data.bcc ? `Bcc: ${data.bcc}` : '',
- `Subject: ${data.subject}`,
- `MIME-Version: 1.0`,
- `Content-Type: multipart/mixed; boundary=${boundary}`,
- '',
- `--${boundary}`,
- 'Content-Type: text/html; charset=utf-8',
- '',
- data.message || '',
- ];
+ const msg = createMimeMessage();
+ msg.setSender('me');
+ msg.setTo(data.to);
+
+ if (data.cc) msg.setCc(data.cc);
+ if (data.bcc) msg.setBcc(data.bcc);
+
+ msg.setSubject(data.subject);
+ msg.addMessage({
+ contentType: 'text/html',
+ data: data.message || ''
+ });
if (data.attachments?.length > 0) {
for (const attachment of data.attachments) {
@@ -861,21 +857,15 @@ export const driver = async (config: IConfig): Promise => {
reader.readAsDataURL(attachment);
});
- messageParts.push(
- `--${boundary}`,
- `Content-Type: ${attachment.type}`,
- `Content-Transfer-Encoding: base64`,
- `Content-Disposition: attachment; filename="${attachment.name}"`,
- '',
- base64Data,
- );
+ msg.addAttachment({
+ filename: attachment.name,
+ contentType: attachment.type,
+ data: base64Data
+ });
}
}
- messageParts.push(`--${boundary}--`);
-
- const mimeMessage = messageParts.filter(Boolean).join('\n');
-
+ const mimeMessage = msg.asRaw();
const encodedMessage = Buffer.from(mimeMessage)
.toString('base64')
.replace(/\+/g, '-')
From 3253ec378fbfb633b3768df1d345e6ef55d05953 Mon Sep 17 00:00:00 2001
From: Ahmet Kilinc
Date: Mon, 21 Apr 2025 02:48:23 +0100
Subject: [PATCH 08/36] add message title to draft list
---
apps/mail/components/draft/drafts-list.tsx | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/apps/mail/components/draft/drafts-list.tsx b/apps/mail/components/draft/drafts-list.tsx
index 89c84332ed..114161d488 100644
--- a/apps/mail/components/draft/drafts-list.tsx
+++ b/apps/mail/components/draft/drafts-list.tsx
@@ -2,9 +2,10 @@
import type { InitialThread, ThreadProps, MailListProps, MailSelectMode } from '@/types';
import { EmptyState, type FolderType } from '@/components/mail/empty-state';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { cn, defaultPageSize, formatDate } from '@/lib/utils';
+import { extractTextFromHTML } from '@/actions/extractText';
import { useSearchValue } from '@/hooks/use-search-value';
import { markAsRead, markAsUnread } from '@/actions/mail';
import { highlightText } from '@/lib/email-utils.client';
@@ -28,6 +29,7 @@ interface DraftProps extends Omit {
const Draft = ({ message, onClick }: DraftProps) => {
const [mail] = useMail();
const [searchValue] = useSearchValue();
+ const [bodyText, setBodyText] = React.useState('');
const isMailSelected = message.id === mail.selected;
const isMailBulkSelected = mail.bulkSelected.includes(message.id);
@@ -94,6 +96,15 @@ const Draft = ({ message, onClick }: DraftProps) => {
>
{highlightText(message.subject, searchValue.highlight)}
+
+ {highlightText(message.title || 'No content', searchValue.highlight)}
+
);
From 281e215c6812cef8a6ac299153999ba4855bc331 Mon Sep 17 00:00:00 2001
From: BlankParticle
Date: Mon, 21 Apr 2025 14:58:03 +0530
Subject: [PATCH 09/36] feat: single api for oauth connections
---
.../mail/auth/[providerId]/callback/route.ts | 67 -----------------
.../v1/mail/auth/[providerId]/init/route.ts | 20 ------
apps/mail/components/connection/add.tsx | 11 ++-
apps/mail/lib/auth.ts | 72 +++++++++++++++++--
apps/mail/lib/constants.ts | 2 +-
apps/mail/package.json | 2 +-
bun.lock | 10 ++-
7 files changed, 82 insertions(+), 102 deletions(-)
delete mode 100644 apps/mail/app/api/v1/mail/auth/[providerId]/callback/route.ts
delete mode 100644 apps/mail/app/api/v1/mail/auth/[providerId]/init/route.ts
diff --git a/apps/mail/app/api/v1/mail/auth/[providerId]/callback/route.ts b/apps/mail/app/api/v1/mail/auth/[providerId]/callback/route.ts
deleted file mode 100644
index f436c7ff57..0000000000
--- a/apps/mail/app/api/v1/mail/auth/[providerId]/callback/route.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { type NextRequest, NextResponse } from "next/server";
-import { createDriver } from "@/app/api/driver";
-import { connection } from "@zero/db/schema";
-import { db } from "@zero/db";
-
-export async function GET(
- request: NextRequest,
- { params }: { params: Promise<{ providerId: string }> },
-) {
- const searchParams = request.nextUrl.searchParams;
- const code = searchParams.get("code");
- const state = searchParams.get("state");
-
- if (!code || !state) {
- return NextResponse.redirect(
- `${process.env.NEXT_PUBLIC_APP_URL}/settings/email?error=missing_params`,
- );
- }
-
- const { providerId } = await params;
-
- const driver = await createDriver(providerId, {});
-
- try {
- // Exchange the authorization code for tokens
- const { tokens } = await driver.getTokens(code);
-
- if (!tokens.access_token || !tokens.refresh_token) {
- console.error("Missing tokens:", tokens);
- return new NextResponse(JSON.stringify({ error: "Could not get token" }), { status: 400 });
- }
-
- // Get user info using the access token
- const userInfo = await driver.getUserInfo({
- access_token: tokens.access_token,
- refresh_token: tokens.refresh_token,
- email: ''
- });
-
- if (!userInfo.data?.emailAddresses?.[0]?.value) {
- console.error("Missing email in user info:", userInfo);
- return new NextResponse(JSON.stringify({ error: 'Missing "email" in user info' }), {
- status: 400,
- });
- }
-
- // Store the connection in the database
- await db.insert(connection).values({
- providerId,
- id: crypto.randomUUID(),
- userId: state,
- email: userInfo.data.emailAddresses[0].value,
- name: userInfo.data.names?.[0]?.displayName || "Unknown",
- picture: userInfo.data.photos?.[0]?.url || "",
- accessToken: tokens.access_token,
- refreshToken: tokens.refresh_token,
- scope: driver.getScope(),
- expiresAt: new Date(Date.now() + (tokens.expiry_date || 3600000)),
- createdAt: new Date(),
- updatedAt: new Date(),
- });
-
- return NextResponse.redirect(new URL("/mail", request.url));
- } catch (error) {
- return new NextResponse(JSON.stringify({ error }));
- }
-}
diff --git a/apps/mail/app/api/v1/mail/auth/[providerId]/init/route.ts b/apps/mail/app/api/v1/mail/auth/[providerId]/init/route.ts
deleted file mode 100644
index b85dec4d36..0000000000
--- a/apps/mail/app/api/v1/mail/auth/[providerId]/init/route.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { type NextRequest, NextResponse } from "next/server";
-import { createDriver } from "@/app/api/driver";
-import { auth } from "@/lib/auth";
-
-export async function GET(
- request: NextRequest,
- { params }: { params: Promise<{ providerId: string }> },
-) {
- const session = await auth.api.getSession({ headers: request.headers });
- const userId = session?.user?.id;
-
- if (!userId) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
- }
-
- const { providerId } = await params;
- const driver = await createDriver(providerId, {});
- const authUrl = driver.generateConnectionAuthUrl(userId);
- return NextResponse.redirect(authUrl);
-}
diff --git a/apps/mail/components/connection/add.tsx b/apps/mail/components/connection/add.tsx
index 9bbed99a13..6d509f88ab 100644
--- a/apps/mail/components/connection/add.tsx
+++ b/apps/mail/components/connection/add.tsx
@@ -7,6 +7,7 @@ import {
DialogTrigger,
} from '../ui/dialog';
import { emailProviders } from '@/lib/constants';
+import { authClient } from '@/lib/auth-client';
import { Plus, UserPlus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '../ui/button';
@@ -52,9 +53,8 @@ export const AddConnectionDialog = ({
transition={{ duration: 0.3 }}
>
{emailProviders.map((provider, index) => (
-
+ await authClient.linkSocial({
+ provider: provider.providerId,
+ })
+ }
>
{provider.name}
-
+
))}
console.log(args) } };
+const connectionHandlerHook = async (account: Account) => {
+ if (!account.accessToken || !account.refreshToken) {
+ console.error('Missing Access/Refresh Tokens', { account });
+ throw new APIError('EXPECTATION_FAILED', { message: 'Missing Access/Refresh Tokens' });
+ }
+
+ const driver = await createDriver(account.providerId, {});
+ const userInfo = await driver.getUserInfo({
+ access_token: account.accessToken,
+ refresh_token: account.refreshToken,
+ email: '',
+ });
+
+ if (!userInfo.data?.emailAddresses?.[0]?.value) {
+ console.error('Missing email in user info:', { userInfo });
+ throw new APIError('BAD_REQUEST', { message: 'Missing "email" in user info' });
+ }
+
+ const updatingInfo = {
+ name: userInfo.data.names?.[0]?.displayName || 'Unknown',
+ picture: userInfo.data.photos?.[0]?.url || '',
+ accessToken: account.accessToken,
+ refreshToken: account.refreshToken,
+ scope: driver.getScope(),
+ expiresAt: new Date(Date.now() + (account.accessTokenExpiresAt?.getTime() || 3600000)),
+ };
+
+ await db
+ .insert(connection)
+ .values({
+ providerId: account.providerId,
+ id: crypto.randomUUID(),
+ email: userInfo.data.emailAddresses[0].value,
+ userId: account.userId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...updatingInfo,
+ })
+ .onConflictDoUpdate({
+ target: [connection.email, connection.userId],
+ set: {
+ ...updatingInfo,
+ updatedAt: new Date(),
+ },
+ });
+};
+
const options = {
- database: drizzleAdapter(db, {
- provider: 'pg',
- }),
+ database: drizzleAdapter(db, { provider: 'pg' }),
advanced: {
ipAddress: {
disableIpTracking: true,
@@ -31,6 +78,23 @@ const options = {
updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
},
socialProviders: getSocialProviders(),
+ account: {
+ accountLinking: {
+ enabled: true,
+ allowDifferentEmails: true,
+ trustedProviders: ['google'],
+ },
+ },
+ databaseHooks: {
+ account: {
+ create: {
+ after: connectionHandlerHook,
+ },
+ update: {
+ after: connectionHandlerHook,
+ },
+ },
+ },
emailAndPassword: {
enabled: false,
requireEmailVerification: true,
diff --git a/apps/mail/lib/constants.ts b/apps/mail/lib/constants.ts
index a99c030f3b..6875462519 100644
--- a/apps/mail/lib/constants.ts
+++ b/apps/mail/lib/constants.ts
@@ -103,4 +103,4 @@ export const emailProviders = [
icon: "M11.99 13.9v-3.72h9.36c.14.63.25 1.22.25 2.05c0 5.71-3.83 9.77-9.6 9.77c-5.52 0-10-4.48-10-10S6.48 2 12 2c2.7 0 4.96.99 6.69 2.61l-2.84 2.76c-.72-.68-1.98-1.48-3.85-1.48c-3.31 0-6.01 2.75-6.01 6.12s2.7 6.12 6.01 6.12c3.83 0 5.24-2.65 5.5-4.22h-5.51z",
providerId: "google",
},
-];
+] as const;
diff --git a/apps/mail/package.json b/apps/mail/package.json
index abdb292d6b..1290999539 100644
--- a/apps/mail/package.json
+++ b/apps/mail/package.json
@@ -60,7 +60,7 @@
"@zero/db": "workspace:*",
"@zero/eslint-config": "workspace:*",
"axios": "1.8.1",
- "better-auth": "1.2.1",
+ "better-auth": "1.2.7",
"canvas-confetti": "1.9.3",
"cheerio": "1.0.0",
"class-variance-authority": "0.7.1",
diff --git a/bun.lock b/bun.lock
index debab39f48..9d9c722985 100644
--- a/bun.lock
+++ b/bun.lock
@@ -71,7 +71,7 @@
"@zero/db": "workspace:*",
"@zero/eslint-config": "workspace:*",
"axios": "1.8.1",
- "better-auth": "1.2.1",
+ "better-auth": "1.2.7",
"canvas-confetti": "1.9.3",
"cheerio": "1.0.0",
"class-variance-authority": "0.7.1",
@@ -195,7 +195,7 @@
"@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.27.0", "", { "dependencies": { "core-js-pure": "^3.30.2", "regenerator-runtime": "^0.14.0" } }, "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew=="],
- "@better-auth/utils": ["@better-auth/utils@0.2.3", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-Ap1GaSmo6JYhJhxJOpUB0HobkKPTNzfta+bLV89HfpyCAHN7p8ntCrmNFHNAVD0F6v0mywFVEUg1FUhNCc81Rw=="],
+ "@better-auth/utils": ["@better-auth/utils@0.2.4", "", { "dependencies": { "typescript": "^5.8.2", "uncrypto": "^0.1.3" } }, "sha512-ayiX87Xd5sCHEplAdeMgwkA0FgnXsEZBgDn890XHHwSWNqqRZDYOq3uj2Ei2leTv1I2KbG5HHn60Ah1i2JWZjQ=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="],
@@ -851,9 +851,9 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
- "better-auth": ["better-auth@1.2.1", "", { "dependencies": { "@better-auth/utils": "0.2.3", "@better-fetch/fetch": "^1.1.15", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.6.1", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.3", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.27.4", "nanostores": "^0.11.3", "valibot": "1.0.0-beta.15", "zod": "^3.24.1" } }, "sha512-ehECh654Y32pseRiAwHiDdqemCX5oM/B/N52heqVcRbgiVKC61FgdrBwBkQb9jV2jBk7E+C8iDZ5Nqshck3O1g=="],
+ "better-auth": ["better-auth@1.2.7", "", { "dependencies": { "@better-auth/utils": "0.2.4", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.6.1", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.8", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.27.6", "nanostores": "^0.11.3", "zod": "^3.24.1" } }, "sha512-2hCB263GSrgetsMUZw8vv9O1e4S4AlYJW3P4e8bX9u3Q3idv4u9BzDFCblpTLuL4YjYovghMCN0vurAsctXOAQ=="],
- "better-call": ["better-call@1.0.7", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-p5kEthErx3HsW9dCCvvEx+uuEdncn0ZrlqrOG3TkR1aVYgynpwYbTVU90nY8/UwfMhROzqZWs8vryainSQxrNg=="],
+ "better-call": ["better-call@1.0.8", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-/PV8JLqDRUN7JyBPbklVsS/8E4SO3pnf8hbpa8B7xrBrr+BBYpeOAxoqtnsyk/pRs35vNB4MZx8cn9dBuNlLDA=="],
"bignumber.js": ["bignumber.js@9.2.0", "", {}, "sha512-JocpCSOixzy5XFJi2ub6IMmV/G9i8Lrm2lZvwBv9xPdglmZM0ufDVBbjbrfU/zuLvBfD7Bv2eYxz9i+OHTgkew=="],
@@ -2053,8 +2053,6 @@
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
- "valibot": ["valibot@1.0.0-beta.15", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-BKy8XosZkDHWmYC+cJG74LBzP++Gfntwi33pP3D3RKztz2XV9jmFWnkOi21GoqARP8wAWARwhV6eTr1JcWzjGw=="],
-
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
From 2763b45185ef9cdce6c3abcd1207a89e66b0e4d7 Mon Sep 17 00:00:00 2001
From: BlankParticle
Date: Mon, 21 Apr 2025 17:22:24 +0530
Subject: [PATCH 10/36] fix: add extra error handling
---
apps/mail/lib/auth.ts | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/apps/mail/lib/auth.ts b/apps/mail/lib/auth.ts
index 198fcaa7fa..46dc71cc2f 100644
--- a/apps/mail/lib/auth.ts
+++ b/apps/mail/lib/auth.ts
@@ -26,11 +26,15 @@ const connectionHandlerHook = async (account: Account) => {
}
const driver = await createDriver(account.providerId, {});
- const userInfo = await driver.getUserInfo({
- access_token: account.accessToken,
- refresh_token: account.refreshToken,
- email: '',
- });
+ const userInfo = await driver
+ .getUserInfo({
+ access_token: account.accessToken,
+ refresh_token: account.refreshToken,
+ email: '',
+ })
+ .catch(() => {
+ throw new APIError('UNAUTHORIZED', { message: 'Failed to get user info' });
+ });
if (!userInfo.data?.emailAddresses?.[0]?.value) {
console.error('Missing email in user info:', { userInfo });
From b3415c3d5b1a37c905164d87a1ced966ed99e2c2 Mon Sep 17 00:00:00 2001
From: BlankParticle
Date: Mon, 21 Apr 2025 18:38:54 +0530
Subject: [PATCH 11/36] chore: simplify and fix the dev env
---
apps/mail/.env.example => .env.example | 6 ---
.github/CONTRIBUTING.md | 25 +++++-----
README.md | 60 +++--------------------
apps/mail/.nvmrc | 1 -
apps/mail/drizzle.config.ts | 11 -----
apps/mail/lib/auth-providers.ts | 10 +---
apps/mail/middleware-eg.ts | 67 --------------------------
apps/mail/package.json | 6 +--
bun.lock | 48 +++++-------------
docker-compose.yaml | 19 +-------
middleware-draft.ts | 23 ---------
package.json | 29 +++++------
packages/db/.env.example | 3 --
turbo.json | 30 ++++++------
14 files changed, 59 insertions(+), 279 deletions(-)
rename apps/mail/.env.example => .env.example (78%)
delete mode 100644 apps/mail/.nvmrc
delete mode 100644 apps/mail/drizzle.config.ts
delete mode 100644 apps/mail/middleware-eg.ts
delete mode 100644 middleware-draft.ts
delete mode 100644 packages/db/.env.example
diff --git a/apps/mail/.env.example b/.env.example
similarity index 78%
rename from apps/mail/.env.example
rename to .env.example
index 0c98f49032..bb3ee92f28 100644
--- a/apps/mail/.env.example
+++ b/.env.example
@@ -10,12 +10,6 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
# Change to your project's client ID and secret, these work with localhost:3000 and localhost:3001
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
-GOOGLE_REDIRECT_URI=http://localhost:3000/api/v1/mail/auth/google/callback
-
-
-GITHUB_CLIENT_ID=
-GITHUB_CLIENT_SECRET=
-GITHUB_REDIRECT_URI=http://localhost:3000/api/auth/callback/github
# Upstash/Local Redis Instance
REDIS_URL="http://localhost:8079"
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 3428728f42..502088e22a 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -23,6 +23,7 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo
## Getting Started
1. **Fork the Repository**
+
- Click the 'Fork' button at the top right of this repository
- Clone your fork locally: `git clone https://github.com/YOUR-USERNAME/Zero.git`
@@ -30,9 +31,8 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo
- Install [Bun](https://bun.sh)
- Clone the repository and install dependencies: `bun install`
- Start the database locally: `bun docker:up`
- - Copy `.env.example` to `.env` in both `apps/mail` and `packages/db` folders
+ - Copy `.env.example` to `.env` in project root
- Set up your Google OAuth credentials (see [README.md](../README.md))
- - Install database dependencies: `bun db:dependencies`
- Initialize the database: `bun db:push`
## Development Workflow
@@ -42,7 +42,7 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo
```bash
# Start database locally
bun docker:up
-
+
# Start the development server
bun dev
```
@@ -112,29 +112,24 @@ Zero uses PostgreSQL with Drizzle ORM. Here's how to work with it:
2. **Common Database Tasks**
```bash
- # Install database dependencies
- bun db:dependencies
-
# Apply schema changes to development database
bun db:push
-
+
# Create migration files after schema changes
bun db:generate
-
+
# Apply migrations (for production)
bun db:migrate
-
+
# View and edit data with Drizzle Studio
bun db:studio
```
3. **Database Connection**
- Make sure your database connection string is in both:
- - `apps/mail/.env`
- - `packages/db/.env`
-
+ Make sure your database connection string is in `.env`
For local development:
+
```
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/zerodotemail"
```
@@ -181,13 +176,15 @@ Zero uses PostgreSQL with Drizzle ORM. Here's how to work with it:
When implementing new features, follow these guidelines:
1. **Add English Source Strings**
+
- Place all user-facing text in `apps/mail/locales/en.json`
- Organize strings according to the existing structure
- Use descriptive, hierarchical keys that identify the feature and context
- Example: `"pages.settings.connections.disconnectSuccess": "Account disconnected successfully"`
2. **Follow i18n Formatting Standards**
- - Variables: `{variableName}`
+
+ - Variables: `{variableName}`
- Pluralization: `{count, plural, =0 {items} one {item} other {items}}`
- Avoid string concatenation to ensure proper translation
diff --git a/README.md b/README.md
index a1d1097288..036666a312 100644
--- a/README.md
+++ b/README.md
@@ -69,21 +69,18 @@ You can set up Zero in two ways:
# Install dependencies
bun install
- # Install database dependencies
- bun db:dependencies
-
# Start database locally
bun docker:up
```
2. **Set Up Environment**
- - Copy `.env.example` to `.env` in both `apps/mail` and `packages/db` folders
+ - Copy `.env.example` to `.env` in project root
```bash
- cp apps/mail/.env.example apps/mail/.env && cp packages/db/.env.example packages/db/.env
+ cp .env.example .env
```
- Configure your environment variables (see below)
- - Install database dependencies: `bun db:dependencies`
+ - Start the database with the provided docker compose setup: `bun docker:up`
- Initialize the database: `bun db:push`
3. **Start the App**
@@ -152,16 +149,13 @@ bun install
- Add authorized redirect URIs:
- Development:
- `http://localhost:3000/api/auth/callback/google`
- - `http://localhost:3000/api/v1/mail/auth/google/callback`
- Production:
- `https://your-production-url/api/auth/callback/google`
- - `https://your-production-url/api/v1/mail/auth/google/callback`
- Add to `.env`:
```env
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
- GOOGLE_REDIRECT_URI=http://localhost:3000/api/v1/mail/auth/google/callback
```
- Add yourself as a test user:
@@ -171,30 +165,11 @@ bun install
- Add your email and click 'Save'
> [!WARNING]
-> The `GOOGLE_REDIRECT_URI` must match **exactly** what you configure in the Google Cloud Console, including the protocol (http/https), domain, and path - these are provided above.
-
-3. **GitHub OAuth Setup** (Optional)
-
-
- Click to expand GitHub OAuth setup instructions
-
- - Go to [GitHub Developer Setting](https://github.com/settings/developers)
- - Create a new OAuth App
- - Add authorized redirect URIs:
- - Development: `http://localhost:3000/api/auth/callback/github`
- - Production: `https://your-production-url/api/auth/callback/github`
- - Add to `.env`:
-
- ```env
- GITHUB_CLIENT_ID=your_client_id
- GITHUB_CLIENT_SECRET=your_client_secret
- ```
-
-
+> The authorized redirect URIs in Google Cloud Console must match **exactly** what you configure in the `.env`, including the protocol (http/https), domain, and path - these are provided above.
### Environment Variables
-Copy `.env.example` located in the `apps/mail` folder to `.env` in the same folder and configure the following variables:
+Copy `.env.example` located in the project folder to `.env` in the same folder and configure the following variables:
```env
# Auth
@@ -203,11 +178,6 @@ BETTER_AUTH_SECRET= # Required: Secret key for authentication
# Google OAuth (Required for Gmail integration)
GOOGLE_CLIENT_ID= # Required for Gmail integration
GOOGLE_CLIENT_SECRET= # Required for Gmail integration
-GOOGLE_REDIRECT_URI= # Required for Gmail integration
-
-# GitHub OAuth (Optional)
-GITHUB_CLIENT_ID= # Optional: For GitHub authentication
-GITHUB_CLIENT_SECRET= # Optional: For GitHub authentication
# Database
DATABASE_URL= # Required: PostgreSQL connection string for backend connection
@@ -217,16 +187,8 @@ REDIS_URL= # Redis URL for caching (http://localhost:8079 for local
REDIS_TOKEN= # Redis token (upstash-local-token for local dev)
```
-To be able to run `bun db:push` and push the schemas to the database you also have to add a `.env` file to the `packages/db` folder (so `packages/db/.env`) with the following content:
-
-```env
-DATABASE_URL= # Required: PostgreSQL connection string for migrations
-```
-
For local development a connection string example is provided in the `.env.example` file located in the same folder as the database.
-**Note:** The `DATABASE_URL` connection string in the `apps/mail/.env` has to be the same as the one in `packages/db/.env`
-
### Database Setup
Zero uses PostgreSQL for storing data. Here's how to set it up:
@@ -248,10 +210,7 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
2. **Set Up Database Connection**
- Make sure your database connection string is in:
-
- - `apps/mail/.env`
- - `packages/db/.env`
+ Make sure your database connection string is in `.env` file.
For local development use:
@@ -261,12 +220,6 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
3. **Database Commands**
- - **Install database dependencies**:
-
- ```bash
- bun db:dependencies
- ```
-
- **Set up database tables**:
```bash
@@ -289,6 +242,7 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
```bash
bun db:studio
```
+ > If you run `bun dev` in your terminal, the studio command should be automatically running with the app.
## Contribute
diff --git a/apps/mail/.nvmrc b/apps/mail/.nvmrc
deleted file mode 100644
index 2812de52ab..0000000000
--- a/apps/mail/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-v22.13
\ No newline at end of file
diff --git a/apps/mail/drizzle.config.ts b/apps/mail/drizzle.config.ts
deleted file mode 100644
index 4735656a74..0000000000
--- a/apps/mail/drizzle.config.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { type Config } from 'drizzle-kit';
-
-export default {
- schema: './db/schema.ts',
- dialect: 'postgresql',
- dbCredentials: {
- url: process.env.DATABASE_URL!,
- },
- out: './db/migrations',
- tablesFilter: ['mail0_*'],
-} satisfies Config;
diff --git a/apps/mail/lib/auth-providers.ts b/apps/mail/lib/auth-providers.ts
index a0e9c07089..134b5450bb 100644
--- a/apps/mail/lib/auth-providers.ts
+++ b/apps/mail/lib/auth-providers.ts
@@ -32,17 +32,11 @@ export const authProviders: ProviderConfig[] = [
name: "Google",
requiredEnvVars: [
"GOOGLE_CLIENT_ID",
- "GOOGLE_CLIENT_SECRET",
- "GOOGLE_REDIRECT_URI"
+ "GOOGLE_CLIENT_SECRET"
],
envVarInfo: [
{ name: "GOOGLE_CLIENT_ID", source: "Google Cloud Console" },
- { name: "GOOGLE_CLIENT_SECRET", source: "Google Cloud Console" },
- {
- name: "GOOGLE_REDIRECT_URI",
- source: "Google Cloud Console",
- defaultValue: "http://localhost:3000/api/v1/mail/auth/google/callback"
- }
+ { name: "GOOGLE_CLIENT_SECRET", source: "Google Cloud Console" }
],
config: {
// TODO: Remove this before going to prod, it's to force to get `refresh_token` from google, some users don't have it yet.
diff --git a/apps/mail/middleware-eg.ts b/apps/mail/middleware-eg.ts
deleted file mode 100644
index bfbc579ce9..0000000000
--- a/apps/mail/middleware-eg.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-// import { type NextRequest, NextResponse } from "next/server";
-// import { createAuthClient } from "better-auth/client";
-
-// const client = createAuthClient();
-
-// // Public routes that don't require authentication
-// const publicRoutes = [
-// "/login",
-// "/signup",
-// "/signup/verify",
-// "/",
-// "/privacy",
-// "/terms",
-// "/api/auth/early-access",
-// ];
-
-// export async function middleware(request: NextRequest) {
-// const { pathname } = request.nextUrl;
-
-// // Check if the current path is a public route
-// const isPublicRoute = publicRoutes.includes(pathname);
-
-// // For non-public routes, check authentication
-// if (!isPublicRoute) {
-// const { data: session } = await client.getSession({
-// fetchOptions: {
-// headers: {
-// cookie: request.headers.get("cookie") || "",
-// },
-// },
-// });
-
-// if (!session) {
-// return NextResponse.redirect(new URL("/login", request.url));
-// }
-// }
-
-// // Handle existing rate limiting for early access
-// if (pathname === "/api/auth/early-access") {
-// const ip = request.headers.get("x-forwarded-for");
-// if (!ip) {
-// return NextResponse.json(
-// {
-// success: false,
-// error: "Could not determine your IP address, please try again later!",
-// },
-// { status: 400 },
-// );
-// }
-// }
-
-// return NextResponse.next();
-// }
-
-// export const config = {
-// matcher: [
-// /*
-// * Match all request paths except for the ones starting with:
-// * - _next/static (static files)
-// * - _next/image (image optimization files)
-// * - favicon.ico (favicon file)
-// * - public files (public folder)
-// * - api routes (except /api/auth/early-access)
-// */
-// "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
-// ],
-// };
diff --git a/apps/mail/package.json b/apps/mail/package.json
index abdb292d6b..607e502a51 100644
--- a/apps/mail/package.json
+++ b/apps/mail/package.json
@@ -7,11 +7,7 @@
"dev:turbo": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint . --cache --cache-location ./node_modules/.cache/eslint",
- "db:generate": "drizzle-kit generate",
- "db:migrate": "drizzle-kit migrate",
- "db:push": "drizzle-kit push",
- "db:studio": "drizzle-kit studio"
+ "lint": "eslint . --cache --cache-location ./node_modules/.cache/eslint"
},
"dependencies": {
"@better-fetch/fetch": "^1.1.18",
diff --git a/bun.lock b/bun.lock
index debab39f48..f75154dcf0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -3,16 +3,10 @@
"workspaces": {
"": {
"name": "zero",
- "dependencies": {
- "@types/dompurify": "^3.2.0",
- "dompurify": "^3.2.5",
- "he": "^1.2.0",
- "openai": "^4.90.0",
- "posthog-js": "^1.234.4",
- },
"devDependencies": {
"@types/node": "22.13.8",
"@zero/tsconfig": "workspace:*",
+ "dotenv-cli": "^8.0.0",
"husky": "9.1.7",
"prettier": "3.5.3",
"prettier-plugin-sort-imports": "1.8.6",
@@ -661,8 +655,6 @@
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
- "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
-
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
@@ -701,8 +693,6 @@
"@types/sanitize-html": ["@types/sanitize-html@2.13.0", "", { "dependencies": { "htmlparser2": "^8.0.0" } }, "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ=="],
- "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
-
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -935,8 +925,6 @@
"copy-to-clipboard": ["copy-to-clipboard@3.3.3", "", { "dependencies": { "toggle-selection": "^1.0.6" } }, "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA=="],
- "core-js": ["core-js@3.41.0", "", {}, "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA=="],
-
"core-js-pure": ["core-js-pure@3.41.0", "", {}, "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
@@ -1039,12 +1027,14 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
- "dompurify": ["dompurify@3.2.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ=="],
-
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
+ "dotenv-cli": ["dotenv-cli@8.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^16.3.0", "dotenv-expand": "^10.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw=="],
+
+ "dotenv-expand": ["dotenv-expand@10.0.0", "", {}, "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A=="],
+
"drizzle-kit": ["drizzle-kit@0.30.5", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l6dMSE100u7sDaTbLczibrQZjA35jLsHNqIV+jmhNVO3O8jzM6kywMOmV9uOz9ZVSCMPQhAZEFjL/qDPVrqpUA=="],
"drizzle-orm": ["drizzle-orm@0.39.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw=="],
@@ -1151,8 +1141,6 @@
"fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="],
- "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
-
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1357,7 +1345,7 @@
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
- "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
@@ -1595,7 +1583,7 @@
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
- "openai": ["openai@4.92.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-rFjyiQF/eHXIuzyoT2qkCY/xmI+zyq9xlMZmOEFkSsyGhc8tpNaf7rW25m5uTddnk6B5gRfRX640onMhAQyTww=="],
+ "openai": ["openai@4.95.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-IqJy+ymeW+k/Wq+2YVN3693OQMMcODRtHEYOlz263MdUwnN/Dwdl9c2EXSxLLtGEHkSHAfvzpDMHI5MaWJKXjQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -1667,10 +1655,6 @@
"postgres": ["postgres@3.4.5", "", {}, "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg=="],
- "posthog-js": ["posthog-js@1.234.10", "", { "dependencies": { "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" }, "peerDependencies": { "@rrweb/types": "2.0.0-alpha.17", "rrweb-snapshot": "2.0.0-alpha.17" }, "optionalPeers": ["@rrweb/types", "rrweb-snapshot"] }, "sha512-PCwfDtvzuQU1PfMVxZ/G6K9vQmBZvoIlYjE+3e5trycCd70rKJbPKAQX5cg0bI5+z5HZTcUQdq1A/NvDsMFQeA=="],
-
- "preact": ["preact@10.26.5", "", {}, "sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w=="],
-
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
@@ -2067,8 +2051,6 @@
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
- "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="],
-
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
@@ -2077,7 +2059,7 @@
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
- "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -2123,8 +2105,6 @@
"@langchain/core/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
- "@langchain/openai/openai": ["openai@4.95.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-IqJy+ymeW+k/Wq+2YVN3693OQMMcODRtHEYOlz263MdUwnN/Dwdl9c2EXSxLLtGEHkSHAfvzpDMHI5MaWJKXjQ=="],
-
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
@@ -2153,8 +2133,6 @@
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@1.0.3", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-slot": "1.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g=="],
- "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
-
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="],
@@ -2179,6 +2157,8 @@
"gel/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
+ "gel/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
+
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"googleapis-common/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
@@ -2287,8 +2267,6 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
- "@langchain/openai/openai/@types/node": ["@types/node@18.19.86", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ=="],
-
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"@zero/mail/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1" } }, "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg=="],
@@ -2353,10 +2331,10 @@
"cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0" }, "optionalPeers": ["@types/react"] }, "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg=="],
- "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
-
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
+ "gel/which/isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
+
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
@@ -2383,8 +2361,6 @@
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "@langchain/openai/openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
-
"@zero/mail/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.26.1", "", {}, "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ=="],
"@zero/mail/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA=="],
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 352a32a703..8e82c35c6d 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -30,26 +30,9 @@ services:
environment:
SRH_MODE: env
SRH_TOKEN: ${REDIS_TOKEN}
- SRH_CONNECTION_STRING: "redis://valkey:6379"
+ SRH_CONNECTION_STRING: 'redis://valkey:6379'
ports:
- 8079:80
-
- app:
- container_name: zerodotemail-app
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - 3000:3000
- depends_on:
- - db
- - valkey
- - upstash-proxy
- environment:
- NODE_ENV: production
- NODE_OPTIONS: --no-experimental-fetch
- DATABASE_URL: ${DATABASE_URL}
- REDIS_URL: ${REDIS_URL}
volumes:
valkey-data:
diff --git a/middleware-draft.ts b/middleware-draft.ts
deleted file mode 100644
index 5261171fd9..0000000000
--- a/middleware-draft.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { NextRequest } from "next/server";
-import { NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
-
-export async function middleware(request: NextRequest) {
- const session = await auth.api.getSession({ headers: request.headers });
-
- // Protect /mail routes
- if (request.nextUrl.pathname.startsWith("/mail")) {
- if (!session) {
- // Redirect to login if not authenticated
- const loginUrl = new URL("/login", request.url);
- return NextResponse.redirect(loginUrl);
- }
- }
-
- return NextResponse.next();
-}
-
-// Configure which routes to run middleware on
-export const config = {
- matcher: ["/mail/:path*"],
-};
diff --git a/package.json b/package.json
index 74ca769deb..6febe5e22a 100644
--- a/package.json
+++ b/package.json
@@ -4,27 +4,27 @@
"private": true,
"packageManager": "bun@1.2.5",
"scripts": {
- "dev": "turbo run dev",
- "build": "turbo run build",
- "start": "turbo run start",
- "lint": "turbo run lint",
+ "prepare": "husky",
+ "dev": "dotenv -- turbo run dev",
+ "build": "dotenv -- turbo run build",
+ "start": "dotenv -- turbo run start",
+ "lint": "dotenv -- turbo run lint",
"format": "prettier --write apps/**/*.{ts,tsx} --log-level silent",
"check": "bun run check:format && bun run lint",
"check:format": "prettier . --check",
- "prepare": "husky",
"lint-staged": "prettier --write --ignore-unknown",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:clean": "docker-compose down -v",
- "db:dependencies": "cd packages/db && bun install",
- "db:generate": "cd packages/db && bun run db:generate",
- "db:migrate": "cd packages/db && bun run db:migrate",
- "db:push": "cd packages/db && bun run db:push",
- "db:studio": "cd packages/db && bun run db:studio"
+ "db:generate": "dotenv -- turbo run db:generate",
+ "db:migrate": "dotenv -- turbo run db:migrate",
+ "db:push": "dotenv -- turbo run db:push",
+ "db:studio": "dotenv -- turbo run db:studio"
},
"devDependencies": {
"@types/node": "22.13.8",
"@zero/tsconfig": "workspace:*",
+ "dotenv-cli": "^8.0.0",
"husky": "9.1.7",
"prettier": "3.5.3",
"prettier-plugin-sort-imports": "1.8.6",
@@ -35,12 +35,5 @@
"workspaces": [
"apps/*",
"packages/*"
- ],
- "dependencies": {
- "@types/dompurify": "^3.2.0",
- "dompurify": "^3.2.5",
- "he": "^1.2.0",
- "openai": "^4.90.0",
- "posthog-js": "^1.234.4"
- }
+ ]
}
diff --git a/packages/db/.env.example b/packages/db/.env.example
deleted file mode 100644
index 3f80625c46..0000000000
--- a/packages/db/.env.example
+++ /dev/null
@@ -1,3 +0,0 @@
-# Database connection string
-# If local development, the connection string is based on the environment variables defined in the docker-compose.yaml file
-DATABASE_URL="postgresql://postgres:postgres@localhost:5432/zerodotemail"
\ No newline at end of file
diff --git a/turbo.json b/turbo.json
index 9a357e4cee..ce98de7151 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,28 +1,14 @@
{
"$schema": "https://turbo.build/schema.json",
+ "envMode": "loose",
"tasks": {
"build": {
- "env": [
- "NODE_ENV",
- "DATABASE_URL",
- "BETTER_AUTH_SECRET",
- "BETTER_AUTH_URL",
- "BETTER_AUTH_TRUSTED_ORIGINS",
- "GOOGLE_CLIENT_ID",
- "GOOGLE_CLIENT_SECRET",
- "GOOGLE_REDIRECT_URI",
- "GITHUB_CLIENT_ID",
- "GITHUB_CLIENT_SECRET",
- "REDIS_URL",
- "REDIS_TOKEN",
- "RESEND_API_KEY",
- "EARLY_ACCESS_ENABLED"
- ],
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"]
},
"dev": {
+ "dependsOn": ["db:studio"],
"persistent": true,
"cache": false
},
@@ -31,6 +17,18 @@
},
"lint": {
"outputs": []
+ },
+ "db:generate": {
+ "cache": false
+ },
+ "db:migrate": {
+ "cache": false
+ },
+ "db:push": {
+ "cache": false
+ },
+ "db:studio": {
+ "cache": false
}
}
}
From 1d376592ff6fd7bda43aeec3a10024b8ee0a512e Mon Sep 17 00:00:00 2001
From: grim <75869731+ripgrim@users.noreply.github.com>
Date: Mon, 21 Apr 2025 14:50:20 -0700
Subject: [PATCH 12/36] Ai generate security (#706)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Create prompts with XML formatting
* Include XML formatted prompts in generate func
* remove unused regex and add helper functions/warnings
* error handling
* Update apps/mail/lib/prompts.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* lint issues
* Update prompts.ts
* https://github.com/Mail-0/Zero/pull/706#discussion_r2049650081
Coderabbit fix 1
* erabbitai bot 3 days ago ⚠️ Potential issue errorOccurred state is stale inside finally React state setters (setErrorOccurred) are asynchronous; the errorOccurred value captured at render time will not yet reflect changes made earlier in the same event loop. Consequently, the logic deciding whether to collapse/expand may run with an outdated flag. - } finally { - setIsLoading(false); - if (!errorOccurred || isAskingQuestion) { - setIsExpanded(true); - } else { - setIsExpanded(false); // Collapse on errors - } - } + } finally { + setIsLoading(false); + // Use a local flag to track errors deterministically + const hadError = isAskingQuestion ? false : !!errorFlagRef.current; + setIsExpanded(!hadError); + } You can create const errorFlagRef = useRef(false); and update errorFlagRef.current = true every time an error is detected, ensuring reliable behaviour irrespective of React batching. Committable suggestion skipped: line range outside the PR's diff.
* https://github.com/Mail-0/Zero/pull/706#discussion_r2049650112
* https://github.com/Mail-0/Zero/pull/706#discussion_r2049650106
* https://github.com/Mail-0/Zero/pull/706#discussion_r2049650097
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
apps/mail/actions/ai.ts | 141 ++++++---
apps/mail/components/create/ai-assistant.tsx | 184 +++++------
apps/mail/lib/ai.ts | 313 ++++++++++++-------
apps/mail/lib/prompts.ts | 142 +++++++++
4 files changed, 525 insertions(+), 255 deletions(-)
create mode 100644 apps/mail/lib/prompts.ts
diff --git a/apps/mail/actions/ai.ts b/apps/mail/actions/ai.ts
index 36a3ea8c74..991b72da60 100644
--- a/apps/mail/actions/ai.ts
+++ b/apps/mail/actions/ai.ts
@@ -1,8 +1,7 @@
// The brain.ts file in /actions should replace this file once ready.
'use server';
-import { throwUnauthorizedGracefully } from '@/app/api/utils';
-import { generateEmailContent } from '@/lib/ai';
+import { generateEmailBody, generateSubjectForEmail } from '@/lib/ai';
import { headers } from 'next/headers';
import { JSONContent } from 'novel';
import { auth } from '@/lib/auth';
@@ -12,92 +11,142 @@ interface UserContext {
email?: string;
}
-interface AIEmailResponse {
+interface AIBodyResponse {
content: string;
jsonContent: JSONContent;
type: 'email' | 'question' | 'system';
}
-export async function generateAIEmailContent({
+export async function generateAIEmailBody({
prompt,
currentContent,
+ subject,
to,
- isEdit = false,
conversationId,
userContext,
}: {
prompt: string;
currentContent?: string;
+ subject?: string;
to?: string[];
- isEdit?: boolean;
conversationId?: string;
userContext?: UserContext;
-}): Promise {
+}): Promise {
try {
const headersList = await headers();
const session = await auth.api.getSession({ headers: headersList });
if (!session?.user) {
- return throwUnauthorizedGracefully();
+ console.error('AI Action Error (Body): Unauthorized');
+ const errorMsg = 'Unauthorized access. Please log in.';
+ return {
+ content: errorMsg,
+ jsonContent: createJsonContentFromBody(errorMsg),
+ type: 'system',
+ };
}
-
- const responses = await generateEmailContent(
+
+ const responses = await generateEmailBody(
prompt,
currentContent,
to,
+ subject,
conversationId,
userContext,
);
- const questionResponse = responses.find((r) => r.type === 'question');
- if (questionResponse) {
- return {
- content: questionResponse.content,
- jsonContent: createJsonContent([questionResponse.content]),
- type: 'question',
- };
+ const response = responses[0];
+ if (!response) {
+ console.error('AI Action Error (Body): Received no response array item from generateEmailBody');
+ const errorMsg = 'AI failed to generate a response.';
+ return {
+ content: errorMsg,
+ jsonContent: createJsonContentFromBody(errorMsg),
+ type: 'system',
+ };
}
- const emailResponses = responses.filter((r) => r.type === 'email');
-
- const cleanedContent = emailResponses
- .map((r) => r.content)
- .join('\n\n')
- .trim();
-
- const paragraphs = cleanedContent.split('\n');
+ console.log("--- Action Layer (Body): Received from generateEmailBody ---");
+ console.log("Raw response object:", JSON.stringify(response, null, 2));
+ console.log("Extracted Body:", response.body);
+ console.log("--- End Action Layer (Body) Log ---");
- const jsonContent = createJsonContent(paragraphs);
+ const responseBody = response.body ?? '';
+
+ if (!responseBody) {
+ console.error('AI Action Error (Body): Missing body field on response');
+ const errorMsg = 'AI returned an unexpected format.';
+ return {
+ content: errorMsg,
+ jsonContent: createJsonContentFromBody(errorMsg),
+ type: 'system',
+ };
+ }
+
+ const jsonContent = createJsonContentFromBody(responseBody);
return {
- content: cleanedContent,
+ content: responseBody,
jsonContent,
- type: 'email',
+ type: response.type,
};
+
} catch (error) {
- console.error('Error generating AI email content:', error);
-
+ console.error('Error in generateAIEmailBody action:', error);
+ const errorMsg = 'Sorry, I encountered an unexpected error while generating the email body.';
return {
- content:
- 'Sorry, I encountered an error while generating content. Please try again with a different prompt.',
- jsonContent: createJsonContent([
- 'Sorry, I encountered an error while generating content. Please try again with a different prompt.',
- ]),
+ content: errorMsg,
+ jsonContent: createJsonContentFromBody(errorMsg),
type: 'system',
};
}
}
-function createJsonContent(paragraphs: string[]): JSONContent {
- if (paragraphs.length === 0) {
- paragraphs = ['Failed to generate content. Please try again with a different prompt.'];
- }
+export async function generateAISubject({
+ body,
+}: {
+ body: string;
+}): Promise {
+ try {
+ const headersList = await headers();
+ const session = await auth.api.getSession({ headers: headersList });
+
+ if (!session?.user) {
+ console.error('AI Action Error (Subject): Unauthorized');
+ return '';
+ }
+
+ if (!body || body.trim() === '') {
+ console.warn('AI Action Warning (Subject): Cannot generate subject for empty body.');
+ return '';
+ }
+
+ const subject = await generateSubjectForEmail(body);
- return {
- type: 'doc',
- content: paragraphs.map((paragraph) => ({
- type: 'paragraph',
- content: paragraph.length ? [{ type: 'text', text: paragraph }] : [],
- })),
- };
+ console.log("--- Action Layer (Subject): Received from generateSubjectForEmail ---");
+ console.log("Generated Subject:", subject);
+ console.log("--- End Action Layer (Subject) Log ---");
+
+ return subject;
+
+ } catch (error) {
+ console.error('Error in generateAISubject action:', error);
+ return '';
+ }
+}
+
+function createJsonContentFromBody(bodyText: string): JSONContent {
+ if (!bodyText || bodyText.trim() === '') {
+ bodyText = 'AI failed to generate content. Please try again.';
+ }
+
+ return {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: bodyText.trim() }],
+ }
+ ],
+ };
}
diff --git a/apps/mail/components/create/ai-assistant.tsx b/apps/mail/components/create/ai-assistant.tsx
index 27ebf65db1..5de8d9a4e7 100644
--- a/apps/mail/components/create/ai-assistant.tsx
+++ b/apps/mail/components/create/ai-assistant.tsx
@@ -1,6 +1,6 @@
import { Sparkles, X, Check, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
-import { generateAIEmailContent } from '@/actions/ai';
+import { generateAIEmailBody, generateAISubject } from '@/actions/ai';
import { useState, useEffect, useRef } from 'react';
import { generateConversationId } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-mobile';
@@ -33,30 +33,6 @@ interface Message {
timestamp: number;
}
-// Utility functions
-const extractSubjectFromContent = (content: string): string | null => {
- const patterns = [
- /subject:\s*([^\n]+)/i,
- /^RE:\s*([^\n]+)/i,
- /^(Dear|Hello|Hi|Greetings).*?\n\n(.{5,60})[.?!]/i,
- /\b(regarding|about|concerning|reference to|in response to)\b[^.!?]*[.!?]/i,
- ];
-
- for (const pattern of patterns) {
- const match = content.match(pattern);
- if (match) {
- if (pattern.toString().includes('Dear|Hello|Hi|Greetings')) {
- return match[2]?.trim() || null;
- } else {
- return match[1]?.trim() || null;
- }
- }
- }
-
- const firstSentence = content.match(/^[^.!?]{5,60}[.!?]/);
- return firstSentence ? firstSentence[0].trim() : null;
-};
-
// Animation variants
const animations = {
container: {
@@ -219,20 +195,22 @@ export const AIAssistant = ({
const [isExpanded, setIsExpanded] = useState(false);
const [prompt, setPrompt] = useState('');
const [isLoading, setIsLoading] = useState(false);
- const [generatedContent, setGeneratedContent] = useState<{
+ const [generatedBody, setGeneratedBody] = useState<{
content: string;
jsonContent: JSONContent;
} | null>(null);
+ const [generatedSubject, setGeneratedSubject] = useState(undefined);
const [showActions, setShowActions] = useState(false);
const [messages, setMessages] = useState([]);
const [isAskingQuestion, setIsAskingQuestion] = useState(false);
- const [suggestedSubject, setSuggestedSubject] = useState('');
+ const [errorOccurred, setErrorOccurred] = useState(false);
// Generate conversation ID immediately without useEffect
const conversationId = generateConversationId();
// Refs
const inputRef = useRef(null);
+ const errorFlagRef = useRef(false);
// Hooks
const isMobile = useIsMobile();
@@ -258,11 +236,12 @@ export const AIAssistant = ({
// Reset states
const resetStates = (includeExpanded = true) => {
setPrompt('');
- setGeneratedContent(null);
+ setGeneratedBody(null);
+ setGeneratedSubject(undefined);
setShowActions(false);
setIsAskingQuestion(false);
+ setErrorOccurred(false);
if (includeExpanded) setIsExpanded(false);
- setSuggestedSubject('');
};
// Handle chat with AI button
@@ -279,87 +258,107 @@ export const AIAssistant = ({
};
// Handle submit
- const handleSubmit = async (e?: React.MouseEvent) => {
+ const handleSubmit = async (e?: React.MouseEvent, overridePrompt?: string): Promise => {
e?.stopPropagation();
- if (!prompt.trim()) return;
+ const promptToUse = overridePrompt || prompt;
+ if (!promptToUse.trim() || isLoading) return;
try {
setIsLoading(true);
+ setErrorOccurred(false);
+ errorFlagRef.current = false;
- // Track AI assistant usage
posthog.capture('Create Email AI Assistant Submit');
+ addMessage('user', promptToUse, 'question');
- // Add user message
- addMessage('user', prompt, 'question');
-
- // Reset states
setIsAskingQuestion(false);
setShowActions(false);
-
- // Call the server action
- const result = await generateAIEmailContent({
- prompt,
- currentContent: generatedContent?.content || currentContent,
+ setGeneratedBody(null);
+ setGeneratedSubject(undefined);
+
+ // --- Step 1: Generate Body ---
+ console.log('AI Assistant: Requesting email body...');
+ const bodyResult = await generateAIEmailBody({
+ prompt: promptToUse,
+ currentContent: generatedBody?.content || currentContent,
+ subject,
to: recipients,
conversationId,
userContext: { name: userName, email: userEmail },
});
-
- // Handle response based on type
- if (result.type === 'question') {
+ console.log('AI Assistant: Received Body Result:', JSON.stringify(bodyResult));
+
+ if (bodyResult.type === 'system') {
+ addMessage('system', bodyResult.content, 'system');
+ toast.error(bodyResult.content || "Failed to generate email body.");
+ setErrorOccurred(true);
+ setPrompt('');
+ throw new Error("Body generation failed with system message.");
+ } else if (bodyResult.type === 'question') {
setIsAskingQuestion(true);
- addMessage('assistant', result.content, 'question');
- } else if (result.type === 'email') {
- setGeneratedContent({
- content: result.content,
- jsonContent: result.jsonContent,
- });
-
- if (!subject || subject.trim() === '') {
- const extractedSubject = extractSubjectFromContent(result.content);
- if (extractedSubject) setSuggestedSubject(extractedSubject);
- }
+ addMessage('assistant', bodyResult.content, 'question');
+ setPrompt('');
+ return; // Stop processing, wait for user answer
+ }
- addMessage('assistant', result.content, 'email');
- setShowActions(true);
+ // Store the generated body
+ setGeneratedBody({
+ content: bodyResult.content,
+ jsonContent: bodyResult.jsonContent,
+ });
+
+ let finalSubject: string | undefined = undefined;
+
+ // --- Step 2: Generate Subject ---
+ if (bodyResult.content && bodyResult.content.trim() !== '') {
+ console.log('AI Assistant: Requesting email subject...');
+ const subjectResult = await generateAISubject({ body: bodyResult.content });
+ console.log('AI Assistant: Received Subject Result:', subjectResult);
+
+ if (subjectResult && subjectResult.trim() !== '') {
+ finalSubject = subjectResult;
+ setGeneratedSubject(finalSubject);
+ addMessage('assistant', `Subject: ${finalSubject}\n\n${bodyResult.content}`, 'email');
+ } else {
+ console.warn('AI Assistant: Subject generation failed or returned empty.');
+ addMessage('assistant', bodyResult.content, 'email');
+ toast.warning("Generated email body, but failed to generate subject.");
+ }
} else {
- addMessage('system', result.content, 'system');
+ console.warn('AI Assistant: Body generation returned empty content.');
+ addMessage('system', "AI generated an empty email body.", 'system');
+ setErrorOccurred(true);
+ throw new Error("Body generation resulted in empty content.");
}
-
+
+ setShowActions(true);
setPrompt('');
+
} catch (error) {
- console.error('AI Assistant Error:', error);
-
- const errorMessage =
- error instanceof Error
- ? error.message
- : 'Failed to generate email content. Please try again.';
- toast.error(errorMessage);
- addMessage('system', errorMessage, 'system');
+ if (!(error instanceof Error && (error.message.includes("Body generation failed") || error.message.includes("Body generation resulted")))) {
+ console.error('AI Assistant Error (handleSubmit):', error);
+ const errorMessage = error instanceof Error ? error.message : 'Failed to generate email content. Please try again.';
+ toast.error(errorMessage);
+ addMessage('system', errorMessage, 'system');
+ }
+ setErrorOccurred(true);
+ errorFlagRef.current = true;
} finally {
setIsLoading(false);
- setIsExpanded(true);
+ // Use a local flag to track errors deterministically
+ const hadError = isAskingQuestion ? false : !!errorFlagRef.current;
+ setIsExpanded(!hadError);
}
};
// Handle accept
const handleAccept = () => {
- if (generatedContent && onContentGenerated) {
- // Extract the actual content from the JSON structure
- const actualContent = generatedContent.content;
-
- // First update subject if available
- if (suggestedSubject) {
- // Pass both the JSON content for the editor and the plaintext content for validation
- onContentGenerated(generatedContent.jsonContent, suggestedSubject);
- } else {
- onContentGenerated(generatedContent.jsonContent);
- }
+ if (generatedBody && onContentGenerated) {
+ onContentGenerated(generatedBody.jsonContent, generatedSubject);
- // Track AI assistant usage
- posthog.capture('Create Email AI Assistant Submit');
+ // Keep posthog event from staging merge
+ posthog.capture('Create Email AI Assistant Accept');
- // Add confirmation message
addMessage('system', 'Email content applied successfully.', 'system');
resetStates();
toast.success('AI content applied to your email');
@@ -375,14 +374,15 @@ export const AIAssistant = ({
// Handle refresh
const handleRefresh = async () => {
- if (prompt.trim()) {
+ // Re-trigger handleSubmit using the last user message
+ const lastUserMessage = [...messages].reverse().find((item) => item.role === 'user');
+ if (lastUserMessage && !isLoading) {
+ const refreshedPrompt = lastUserMessage.content;
+ setPrompt(refreshedPrompt);
+ await handleSubmit(undefined, refreshedPrompt);
+ } else if (prompt.trim() && !isLoading) {
+ // If there's text in the input but no history, submit that
await handleSubmit();
- } else if (messages.length > 0) {
- const lastUserMessage = [...messages].reverse().find((item) => item.role === 'user');
- if (lastUserMessage) {
- setPrompt(lastUserMessage.content);
- setTimeout(() => handleSubmit(), 0);
- }
}
};
@@ -417,8 +417,8 @@ export const AIAssistant = ({
>
{/* Floating card for generated content */}
- {showActions && generatedContent && (
-
+ {showActions && generatedBody && (
+
)}
@@ -472,7 +472,7 @@ export const AIAssistant = ({
onRefresh={handleRefresh}
onSubmit={handleSubmit}
onAccept={handleAccept}
- hasContent={!!generatedContent}
+ hasContent={!!generatedBody && !errorOccurred}
hasPrompt={!!prompt.trim()}
animations={animations}
/>
diff --git a/apps/mail/lib/ai.ts b/apps/mail/lib/ai.ts
index 6cbb3e5e96..e4283aae5b 100644
--- a/apps/mail/lib/ai.ts
+++ b/apps/mail/lib/ai.ts
@@ -3,179 +3,258 @@ import { createEmbeddings, generateCompletions } from './groq';
import { generateConversationId } from './utils';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
+import {
+ EmailAssistantSystemPrompt,
+ SubjectGenerationSystemPrompt // Import the prompts
+} from './prompts';
-interface AIResponse {
+// AIResponse for Body Generation
+interface AIBodyResponse {
id: string;
- content: string;
+ body: string; // Only body is returned
type: 'email' | 'question' | 'system';
position?: 'start' | 'end' | 'replace';
}
-// Define user context type
+// User context type
interface UserContext {
name?: string;
email?: string;
}
+// Keyed by user to prevent cross‑tenant bleed‑through and allow GC per‑user
const conversationHistories: Record<
- string,
- { role: 'user' | 'assistant' | 'system'; content: string }[]
+ string, // userId
+ Record<
+ string, // conversationId
+ { role: 'user' | 'assistant' | 'system'; content: string }[]
+ >
> = {};
-export async function generateEmailContent(
+// --- Generate Email Body ---
+export async function generateEmailBody(
prompt: string,
currentContent?: string,
recipients?: string[],
+ subject?: string, // Subject for context only
conversationId?: string,
userContext?: UserContext,
-): Promise {
+): Promise { // Returns body-focused response
const headersList = await headers();
const session = await auth.api.getSession({ headers: headersList });
+ const userName = session?.user.name || 'User';
+ const convId = conversationId || generateConversationId();
+ const userId = session?.user?.id || 'anonymous';
+
+ console.log(`AI Assistant (Body): Processing prompt for convId ${convId}: "${prompt}"`);
+
+ const genericFailureMessage = "Unable to fulfill your request.";
try {
if (!process.env.GROQ_API_KEY) {
throw new Error('Groq API key is not configured');
}
- // Get or initialize conversation
- const convId = conversationId || generateConversationId();
- if (!conversationHistories[convId]) {
- conversationHistories[convId] = [
- { role: 'system', content: process.env.AI_SYSTEM_PROMPT || 'You are an email assistant.' },
- ];
-
- // Add user context if available
- if (userContext?.name) {
- conversationHistories[convId].push({
- role: 'system',
- content: `User name: ${userContext.name}. Always sign emails with ${userContext.name}.`,
- });
- }
+ // Initialize nested structure if needed
+ if (!conversationHistories[userId]) {
+ conversationHistories[userId] = {};
+ }
+ if (!conversationHistories[userId][convId]) {
+ conversationHistories[userId][convId] = [];
}
- // Add user message to history
- conversationHistories[convId].push({ role: 'user', content: prompt });
-
- // Check if this is a question about the email
- const isQuestion = checkIfQuestion(prompt);
+ // Use the BODY-ONLY system prompt
+ const baseSystemPrompt = EmailAssistantSystemPrompt(userName);
- // Build system prompt from conversation history and context
- let systemPrompt = '';
- const systemMessages = conversationHistories[convId].filter((msg) => msg.role === 'system');
- if (systemMessages.length > 0) {
- systemPrompt = systemMessages.map((msg) => msg.content).join('\n\n');
+ // Dynamic context (can still include subject)
+ let dynamicContext = '\n\n\n';
+ if (subject) {
+ dynamicContext += ` ${subject}\n`;
}
-
- // Add context about current email if it exists
if (currentContent) {
- systemPrompt += `\n\nThe user's current email draft is:\n\n${currentContent}`;
+ dynamicContext += ` ${currentContent}\n`;
}
-
- // Add context about recipients
if (recipients && recipients.length > 0) {
- systemPrompt += `\n\nThe email is addressed to: ${recipients.join(', ')}`;
+ dynamicContext += ` ${recipients.join(', ')}\n`;
}
+ dynamicContext += '\n';
+ const fullSystemPrompt = baseSystemPrompt + (dynamicContext.length > 30 ? dynamicContext : '');
- // Build user prompt from conversation history
- const userMessages = conversationHistories[convId]
+ // Build conversation history string
+ const conversationHistory = conversationHistories[userId][convId]
.filter((msg) => msg.role === 'user' || msg.role === 'assistant')
- .map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
- .join('\n\n');
+ .map((msg) => `${msg.content}`)
+ .join('\n');
+
+ // Combine history with current prompt
+ const fullPrompt = conversationHistory + `\n${prompt}`;
- // Create embeddings for relevant context
+ // Prepare embeddings context
const embeddingTexts: Record = {};
-
- if (currentContent) {
- embeddingTexts.currentEmail = currentContent;
- }
-
- if (prompt) {
- embeddingTexts.userPrompt = prompt;
- }
-
- // Add previous messages for context
- const previousMessages = conversationHistories[convId]
- .filter((msg) => msg.role === 'user' || msg.role === 'assistant')
- .slice(-4); // Get last 4 messages
-
+ if (currentContent) { embeddingTexts.currentEmail = currentContent; }
+ if (prompt) { embeddingTexts.userPrompt = prompt; }
+ const previousMessages = conversationHistories[userId][convId].slice(-4);
if (previousMessages.length > 0) {
- embeddingTexts.conversationHistory = previousMessages
- .map((msg) => `${msg.role}: ${msg.content}`)
- .join('\n\n');
+ embeddingTexts.conversationHistory = previousMessages.map((msg) => `${msg.role}: ${msg.content}`).join('\n\n');
}
-
- // Generate embeddings
let embeddings = {};
- try {
- embeddings = await createEmbeddings(embeddingTexts);
- } catch (embeddingError) {
- console.error(embeddingError);
- }
+ try { embeddings = await createEmbeddings(embeddingTexts); } catch (e) { console.error('Embedding error:', e); }
- // Make API call using the ai function
- const { completion } = await generateCompletions({
- model: 'gpt-4o-mini', // Using Groq's model
- systemPrompt,
- prompt: userMessages + '\n\nUser: ' + prompt,
+ console.log(`AI Assistant (Body): Calling generateCompletions for convId ${convId}...`);
+ const { completion: generatedBodyRaw } = await generateCompletions({
+ model: 'gpt-4', // Using the more capable model
+ systemPrompt: fullSystemPrompt,
+ prompt: fullPrompt,
temperature: 0.7,
- embeddings, // Pass the embeddings to the API call
- userName: session?.user.name || 'User',
+ embeddings,
+ userName: userName,
});
+ console.log(`AI Assistant (Body): Received completion for convId ${convId}:`, generatedBodyRaw);
- const generatedContent = completion;
-
- // Add assistant response to conversation history
- conversationHistories[convId].push({ role: 'assistant', content: generatedContent });
+ // --- Post-processing: Remove common conversational prefixes ---
+ let generatedBody = generatedBodyRaw;
+ const prefixesToRemove = [
+ /^Here is the generated email body:/i,
+ /^Sure, here's the email body:/i,
+ /^Okay, here is the body:/i,
+ /^Here's the draft:/i,
+ /^Here is the email body:/i,
+ /^Here is your email body:/i,
+ // Add more prefixes if needed
+ ];
+ for (const prefixRegex of prefixesToRemove) {
+ if (prefixRegex.test(generatedBody.trimStart())) {
+ generatedBody = generatedBody.trimStart().replace(prefixRegex, '').trimStart();
+ console.log(`AI Assistant Post-Check (Body): Removed prefix matching ${prefixRegex}`);
+ break;
+ }
+ }
+ // --- End Post-processing ---
- // Format and return the response
- if (isQuestion) {
- return [
- {
- id: 'question-' + Date.now(),
- content: generatedContent,
- type: 'question',
- position: 'replace',
- },
- ];
- } else {
+ // Comprehensive safety checks for HTML tags and code blocks
+ const unsafePattern = /(```|~~~|<[^>]+>|<[^&]+>|