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/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/actions/email-aliases.ts b/apps/mail/actions/email-aliases.ts new file mode 100644 index 0000000000..23abaf9432 --- /dev/null +++ b/apps/mail/actions/email-aliases.ts @@ -0,0 +1,36 @@ +'use server'; + +import { throwUnauthorizedGracefully } from '@/app/api/utils'; +import { createDriver } from '@/app/api/driver'; +import { getActiveConnection } from './utils'; + +export type EmailAlias = { + email: string; + name?: string; + primary?: boolean; +}; + +export async function getEmailAliases(): Promise { + const connection = await getActiveConnection(); + + if (!connection?.accessToken || !connection.refreshToken) { + console.error('Unauthorized: No valid connection found'); + return []; + } + + const driver = await createDriver(connection.providerId, { + auth: { + access_token: connection.accessToken, + refresh_token: connection.refreshToken, + email: connection.email, + }, + }); + + try { + const aliases = await driver.getEmailAliases(); + return aliases; + } catch (error) { + console.error('Error fetching email aliases:', error); + return [{ email: connection.email, primary: true }]; + } +} diff --git a/apps/mail/actions/send.ts b/apps/mail/actions/send.ts index d1ba991a94..973505a03c 100644 --- a/apps/mail/actions/send.ts +++ b/apps/mail/actions/send.ts @@ -14,6 +14,7 @@ export async function sendEmail({ cc, headers: additionalHeaders = {}, threadId, + fromEmail, }: { to: Sender[]; subject: string; @@ -23,6 +24,7 @@ export async function sendEmail({ cc?: Sender[]; bcc?: Sender[]; threadId?: string; + fromEmail?: string; }) { if (!to || !subject || !message) { throw new Error('Missing required fields'); @@ -51,6 +53,7 @@ export async function sendEmail({ cc, bcc, threadId, + fromEmail, }); return { success: true }; diff --git a/apps/mail/app/api/auth/early-access/count/route.ts b/apps/mail/app/api/auth/early-access/count/route.ts index d9992368d7..e372b12049 100644 --- a/apps/mail/app/api/auth/early-access/count/route.ts +++ b/apps/mail/app/api/auth/early-access/count/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Ratelimit } from '@upstash/ratelimit'; import { earlyAccess } from '@zero/db/schema'; +import { processIP } from '@/app/api/utils'; import { count } from 'drizzle-orm'; import { redis } from '@/lib/redis'; import { db } from '@zero/db'; @@ -14,17 +15,7 @@ const ratelimit = new Ratelimit({ export async function GET(req: NextRequest) { try { - const ip = req.headers.get('CF-Connecting-IP'); - if (!ip) { - console.log('No IP detected'); - return NextResponse.json({ error: 'No IP detected' }, { status: 400 }); - } - console.log( - 'Request from IP:', - ip, - req.headers.get('x-forwarded-for'), - req.headers.get('CF-Connecting-IP'), - ); + const ip = processIP(req); const { success, limit, reset, remaining } = await ratelimit.limit(ip); const headers = { diff --git a/apps/mail/app/api/auth/early-access/route.ts b/apps/mail/app/api/auth/early-access/route.ts index b86b5a95db..f42e61b2f9 100644 --- a/apps/mail/app/api/auth/early-access/route.ts +++ b/apps/mail/app/api/auth/early-access/route.ts @@ -1,9 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server'; import { Ratelimit } from '@upstash/ratelimit'; import { earlyAccess } from '@zero/db/schema'; +import { processIP } from '../../utils'; import { redis } from '@/lib/redis'; -import { db } from '@zero/db'; import { Resend } from 'resend'; +import { db } from '@zero/db'; type PostgresError = { code: string; @@ -31,17 +32,7 @@ function isEmail(email: string): boolean { export async function POST(req: NextRequest) { try { - const ip = req.headers.get('CF-Connecting-IP'); - if (!ip) { - console.log('No IP detected'); - return NextResponse.json({ error: 'No IP detected' }, { status: 400 }); - } - console.log( - 'Request from IP:', - ip, - req.headers.get('x-forwarded-for'), - req.headers.get('CF-Connecting-IP'), - ); + const ip = processIP(req); const { success, limit, reset, remaining } = await ratelimit.limit(ip); const headers = { @@ -96,7 +87,7 @@ export async function POST(req: NextRequest) { to: email, subject: 'You <> Zero', text: `Congrats on joining the waitlist! We're excited to have you on board. Please expect an email from us soon with more information, we are inviting more batches of early access users every day. If you have any questions, please don't hesitate to reach out to us on Discord https://discord.gg/0email.`, - scheduledAt: new Date(Date.now() + (1000 * 60 * 60 * 24)).toISOString(), + scheduledAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), }); console.log('Insert successful:', result); diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts index 919fcac4fe..2e8e29a54e 100644 --- a/apps/mail/app/api/driver/google.ts +++ b/apps/mail/app/api/driver/google.ts @@ -229,14 +229,17 @@ export const driver = async (config: IConfig): Promise => { headers, cc, bcc, + fromEmail, }: IOutgoingMessage) => { const msg = createMimeMessage(); - const fromEmail = config.auth?.email || 'nobody@example.com'; - console.log('Debug - From email:', fromEmail); + const defaultFromEmail = config.auth?.email || 'nobody@example.com'; + // Use the specified fromEmail if available, otherwise use the default + const senderEmail = fromEmail || defaultFromEmail; + console.log('Debug - From email:', senderEmail); console.log('Debug - Original to recipients:', JSON.stringify(to, null, 2)); - msg.setSender({ name: '', addr: fromEmail }); + msg.setSender({ name: '', addr: senderEmail }); // Track unique recipients to avoid duplicates const uniqueRecipients = new Set(); @@ -265,7 +268,7 @@ export const driver = async (config: IConfig): Promise => { normalizedEmail: email, fromEmail, isDuplicate: uniqueRecipients.has(email), - isSelf: email === fromEmail, + isSelf: email === senderEmail, }); // Only check for duplicates, allow sending to yourself @@ -308,7 +311,7 @@ export const driver = async (config: IConfig): Promise => { const ccRecipients = cc .filter((recipient) => { const email = recipient.email.toLowerCase(); - if (!uniqueRecipients.has(email) && email !== fromEmail) { + if (!uniqueRecipients.has(email) && email !== senderEmail) { uniqueRecipients.add(email); return true; } @@ -329,7 +332,7 @@ export const driver = async (config: IConfig): Promise => { const bccRecipients = bcc .filter((recipient) => { const email = recipient.email.toLowerCase(); - if (!uniqueRecipients.has(email) && email !== fromEmail) { + if (!uniqueRecipients.has(email) && email !== senderEmail) { uniqueRecipients.add(email); return true; } @@ -482,6 +485,44 @@ export const driver = async (config: IConfig): Promise => { throw error; } }, + getEmailAliases: async () => { + try { + // First, get the user's primary email + const profile = await gmail.users.getProfile({ + userId: 'me', + }); + + const primaryEmail = profile.data.emailAddress || ''; + const aliases: { email: string; name?: string; primary?: boolean }[] = [ + { email: primaryEmail, primary: true }, + ]; + + // Fetch the Gmail settings for aliases + const settings = await gmail.users.settings.sendAs.list({ + userId: 'me', + }); + + if (settings.data.sendAs) { + settings.data.sendAs.forEach((alias) => { + // Skip the primary email which we already added + if (alias.isPrimary && alias.sendAsEmail === primaryEmail) { + return; + } + + aliases.push({ + email: alias.sendAsEmail || '', + name: alias.displayName || undefined, + primary: alias.isPrimary || false, + }); + }); + } + + return aliases; + } catch (error) { + console.error('Error fetching email aliases:', error); + return []; + } + }, markAsRead: async (threadIds: string[]) => { await modifyThreadLabels(threadIds, { removeLabelIds: ['UNREAD'] }); }, @@ -833,15 +874,44 @@ export const driver = async (config: IConfig): Promise => { } }, createDraft: async (data: any) => { - const mimeMessage = [ - `From: me`, - `To: ${data.to}`, - `Subject: ${data.subject}`, - 'Content-Type: text/html; charset=utf-8', - '', - data.message, - ].join('\n'); + 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) { + 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); + }); + + msg.addAttachment({ + filename: attachment.name, + contentType: attachment.type, + data: base64Data + }); + } + } + const mimeMessage = msg.asRaw(); const encodedMessage = Buffer.from(mimeMessage) .toString('base64') .replace(/\+/g, '-') diff --git a/apps/mail/app/api/driver/types.ts b/apps/mail/app/api/driver/types.ts index 6cb1bbce26..520828d25b 100644 --- a/apps/mail/app/api/driver/types.ts +++ b/apps/mail/app/api/driver/types.ts @@ -36,6 +36,7 @@ export interface MailManager { options: { addLabels: string[]; removeLabels: string[] }, ): Promise; getAttachment(messageId: string, attachmentId: string): Promise; + getEmailAliases(): Promise<{ email: string; name?: string; primary?: boolean }[]>; } export interface IConfig { diff --git a/apps/mail/app/api/utils.ts b/apps/mail/app/api/utils.ts index d95ab2b64a..fcdfd8c722 100644 --- a/apps/mail/app/api/utils.ts +++ b/apps/mail/app/api/utils.ts @@ -25,6 +25,7 @@ export const throwUnauthorizedGracefully = async () => { try { const headersList = await headers(); await auth.api.signOut({ headers: headersList }); + throw new Error('Unauthorized'); } catch (error) { console.warn('Error signing out & redirecting to login:', error); throw error; 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/app/manifest.ts b/apps/mail/app/manifest.ts index 97f7d6ce6a..88ef746fb6 100644 --- a/apps/mail/app/manifest.ts +++ b/apps/mail/app/manifest.ts @@ -5,16 +5,29 @@ export default function manifest(): MetadataRoute.Manifest { name: 'Zero', short_name: '0', description: 'Zero - the first open source email app that puts your privacy and safety first.', - start_url: '/', + scope: "/", + start_url: "/mail/inbox", display: 'standalone', background_color: '#000', theme_color: '#fff', icons: [ { - src: '/favicon.ico', - sizes: 'any', - type: 'image/x-icon', + src: "/icons-pwa/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "any maskable" }, - ], + { + src: "/icons-pwa/icon-192.png", + sizes: "192x192", + type: "image/png", + purpose: "any maskable" + }, + { + src: "/icons-pwa/icon-180.png", + sizes: "180x180", + type: "image/png" + } + ] }; } 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} - + ))} { - 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/components/create/create-email.tsx b/apps/mail/components/create/create-email.tsx index 45defc5ea3..ce00b78dc3 100644 --- a/apps/mail/components/create/create-email.tsx +++ b/apps/mail/components/create/create-email.tsx @@ -5,9 +5,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { ArrowUpIcon, MinusCircle, Paperclip, PlusCircle, X, ChevronDown } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { ArrowUpIcon, MinusCircle, Paperclip, PlusCircle, X } from 'lucide-react'; import { UploadedFileIcon } from '@/components/create/uploaded-file-icon'; +import { useEmailAliases } from '@/hooks/use-email-aliases'; import { generateHTML, generateJSON } from '@tiptap/core'; import { useConnections } from '@/hooks/use-connections'; import { createDraft, getDraft } from '@/actions/drafts'; @@ -87,6 +88,7 @@ export function CreateEmail({ const [bccEmails, setBccEmails] = React.useState([]); const [showCc, setShowCc] = React.useState(false); const [showBcc, setShowBcc] = React.useState(false); + const [selectedFromEmail, setSelectedFromEmail] = React.useState(null); const [subjectInput, setSubjectInput] = React.useState(initialSubject); const [attachments, setAttachments] = React.useState([]); const [resetEditorKey, setResetEditorKey] = React.useState(0); @@ -115,6 +117,7 @@ export function CreateEmail({ const { data: session } = useSession(); const { data: connections } = useConnections(); + const { aliases, isLoading: isLoadingAliases } = useEmailAliases(); const activeAccount = React.useMemo(() => { if (!session) return null; @@ -173,12 +176,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); @@ -255,12 +270,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, @@ -314,6 +331,10 @@ export function CreateEmail({ try { setIsLoading(true); + + // Use the selected from email or the first alias (or default user email) + const fromEmail = selectedFromEmail || (aliases?.[0]?.email ?? userEmail); + await sendEmail({ to: toEmails.map((email) => ({ email, name: email.split('@')[0] || email })), cc: showCc @@ -325,6 +346,7 @@ export function CreateEmail({ subject: subjectInput, message: messageContent, attachments: attachments, + fromEmail: fromEmail, }); // Track different email sending scenarios @@ -550,7 +572,7 @@ export function CreateEmail({ onAddEmail={handleAddEmail} hasUnsavedChanges={hasUnsavedChanges} setHasUnsavedChanges={setHasUnsavedChanges} - className='w-24 text-right' + className="w-24 text-right" /> {showCc && ( @@ -565,7 +587,7 @@ export function CreateEmail({ onAddEmail={handleAddEmail} hasUnsavedChanges={hasUnsavedChanges} setHasUnsavedChanges={setHasUnsavedChanges} - className='w-24 text-right' + className="w-24 text-right" /> )} @@ -581,10 +603,49 @@ export function CreateEmail({ onAddEmail={handleAddEmail} hasUnsavedChanges={hasUnsavedChanges} setHasUnsavedChanges={setHasUnsavedChanges} - className='w-24 text-right' + className="w-24 text-right" /> )} + + + {t('common.searchBar.from')} + + + + + {selectedFromEmail || aliases?.[0]?.email || userEmail} + + + + + {isLoadingAliases ? ( + Loading... + ) : aliases && aliases.length > 0 ? ( + aliases.map((alias) => ( + setSelectedFromEmail(alias.email)} + className="cursor-pointer" + > + {alias.name ? `${alias.name} <${alias.email}>` : alias.email} + {alias.primary && ' (Primary)'} + + )) + ) : ( + {userEmail} + )} + + + + {t('common.searchBar.subject')} diff --git a/apps/mail/components/draft/drafts-list.tsx b/apps/mail/components/draft/drafts-list.tsx index a659058218..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'; @@ -19,9 +20,16 @@ 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(); + const [bodyText, setBodyText] = React.useState(''); const isMailSelected = message.id === mail.selected; const isMailBulkSelected = mail.bulkSelected.includes(message.id); @@ -51,7 +59,20 @@ const Draft = ({ message, onClick }: ThreadProps) => { )} > - {highlightText(message.sender.name, searchValue.highlight)} + {!message.to?.length || + 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, + )} @@ -75,6 +96,15 @@ const Draft = ({ message, onClick }: ThreadProps) => { > {highlightText(message.subject, searchValue.highlight)} + + {highlightText(message.title || 'No content', searchValue.highlight)} + ); diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index c0067bbddc..382f50e9bc 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -14,6 +14,7 @@ import { MinusCircle, PlusCircle, Minus, + ChevronDown, } from 'lucide-react'; import { cleanEmailAddress, @@ -23,10 +24,17 @@ import { createAIJsonContent, constructReplyBody, } from '@/lib/utils'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useRef, useState, useEffect, useCallback, useReducer } from 'react'; import { UploadedFileIcon } from '@/components/create/uploaded-file-icon'; import { EmailInput } from '@/components/create/email-input'; +import { useEmailAliases } from '@/hooks/use-email-aliases'; import { extractTextFromHTML } from '@/actions/extractText'; import { useForm, SubmitHandler } from 'react-hook-form'; import { generateAIResponse } from '@/actions/ai-reply'; @@ -51,6 +59,18 @@ import { toast } from 'sonner'; import type { z } from 'zod'; import React from 'react'; +const DragOverlay = () => { + const t = useTranslations(); + return ( + + + + {t('common.replyCompose.dropFiles')} + + + ); +}; + // Utility function to check if an email is a noreply address const isNoReplyAddress = (email: string): boolean => { const lowerEmail = email.toLowerCase(); @@ -161,6 +181,8 @@ export default function ReplyCompose() { const [isEditingRecipients, setIsEditingRecipients] = useState(false); const [showCc, setShowCc] = useState(false); const [showBcc, setShowBcc] = useState(false); + const [selectedFromEmail, setSelectedFromEmail] = useState(null); + const { aliases, isLoading: isLoadingAliases } = useEmailAliases(); const ccInputRef = useRef(null); const bccInputRef = useRef(null); @@ -323,6 +345,7 @@ export default function ReplyCompose() { subject, message: replyBody, attachments, + fromEmail: selectedFromEmail || aliases?.[0]?.email || userEmail, headers: { 'In-Reply-To': inReplyTo ?? '', References: references, @@ -619,7 +642,8 @@ export default function ReplyCompose() { // Helper function to initialize recipients based on mode const initializeRecipients = useCallback(() => { - if (!emailData || !emailData.messages || emailData.messages.length === 0) return { to: [], cc: [] }; + if (!emailData || !emailData.messages || emailData.messages.length === 0) + return { to: [], cc: [] }; const latestMessage = emailData.messages[0]; if (!latestMessage) return { to: [], cc: [] }; @@ -644,7 +668,7 @@ export default function ReplyCompose() { if (senderEmail === userEmail && latestMessage.to && latestMessage.to.length > 0) { // Get the first recipient that isn't us const firstRecipient = latestMessage.to.find( - recipient => recipient.email?.toLowerCase() !== userEmail + (recipient) => recipient.email?.toLowerCase() !== userEmail, ); if (firstRecipient?.email) { to.push(firstRecipient.email); @@ -725,7 +749,7 @@ export default function ReplyCompose() { if (isEditingRecipients || mode === 'forward') { return ( - + {icon} @@ -750,6 +774,43 @@ export default function ReplyCompose() { } /> + + From: + + + + + {selectedFromEmail || aliases?.[0]?.email || session?.activeConnection?.email} + + + + + + {isLoadingAliases ? ( + Loading... + ) : aliases && aliases.length > 0 ? ( + aliases.map((alias) => ( + setSelectedFromEmail(alias.email)} + className="cursor-pointer" + > + {alias.name ? `${alias.name} <${alias.email}>` : alias.email} + {alias.primary && ' (Primary)'} + + )) + ) : ( + + {session?.activeConnection?.email} + + )} + + + + {showCc && ( setIsEditingRecipients(true)} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - setIsEditingRecipients(true); - } - }} - > - {icon} - - {recipientDisplay || t('common.mailDisplay.to')} - + + setIsEditingRecipients(true)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + setIsEditingRecipients(true); + } + }} + > + {icon} + To: + + {recipientDisplay || t('common.mailDisplay.to')} + + + + From: + + + + + {selectedFromEmail || aliases?.[0]?.email || session?.activeConnection?.email} + + + + + + {isLoadingAliases ? ( + Loading... + ) : aliases && aliases.length > 0 ? ( + aliases.map((alias) => ( + setSelectedFromEmail(alias.email)} + className="cursor-pointer" + > + {alias.name ? `${alias.name} <${alias.email}>` : alias.email} + {alias.primary && ' (Primary)'} + + )) + ) : ( + + {session?.activeConnection?.email} + + )} + + + ); }; @@ -970,9 +1070,9 @@ export default function ReplyCompose() { {composerState.isDragging && } {/* Header */} - - {renderHeaderContent()} - + + {renderHeaderContent()} + ); } - -// Extract smaller components -const DragOverlay = () => { - const t = useTranslations(); - return ( - - - - {t('common.replyCompose.dropFiles')} - - - ); -}; 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/hooks/use-email-aliases.ts b/apps/mail/hooks/use-email-aliases.ts new file mode 100644 index 0000000000..4ff2c46dc2 --- /dev/null +++ b/apps/mail/hooks/use-email-aliases.ts @@ -0,0 +1,20 @@ +'use client'; + +import type { EmailAlias } from '../actions/email-aliases'; +import useSWRImmutable from 'swr/immutable'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export function useEmailAliases() { + const { data, error, isLoading, mutate } = useSWRImmutable( + '/api/driver/email-aliases', + fetcher, + ); + + return { + aliases: data || [], + isLoading, + error, + mutate, + }; +} diff --git a/apps/mail/i18n/config.ts b/apps/mail/i18n/config.ts index 6f16e5c297..f5b2e1b593 100644 --- a/apps/mail/i18n/config.ts +++ b/apps/mail/i18n/config.ts @@ -15,6 +15,7 @@ const LANGUAGES = { lv: 'Latvian', hu: 'Hungarian', fa: 'Farsi', + vi: 'Vietnamese', } as const; export type Locale = keyof typeof LANGUAGES; 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 = /(```|~~~|<[^>]+>|<[^&]+>|
+ {highlightText(message.title || 'No content', searchValue.highlight)} +
{t('common.replyCompose.dropFiles')}
- {recipientDisplay || t('common.mailDisplay.to')} -
+ {recipientDisplay || t('common.mailDisplay.to')} +