diff --git a/apps/mail/actions/connections.ts b/apps/mail/actions/connections.ts index 7d71845453..41c40f9b01 100644 --- a/apps/mail/actions/connections.ts +++ b/apps/mail/actions/connections.ts @@ -1,44 +1,12 @@ -"use server"; +'use server'; -import { connection, user } from "@zero/db/schema"; -import { headers } from "next/headers"; -import { and, eq } from "drizzle-orm"; -import { type IConnection } from "@/types"; -import { auth } from "@/lib/auth"; -import { db } from "@zero/db"; - -export async function getConnections() { - try { - const headersList = await headers(); - const session = await auth.api.getSession({ headers: headersList }); - - if (!session) { - throw new Error("Unauthorized, reconnect"); - } - - const userId = session?.user?.id; - - if (!userId) { - throw new Error("Unauthorized, reconnect"); - } - - const connections = (await db - .select({ - id: connection.id, - email: connection.email, - name: connection.name, - picture: connection.picture, - createdAt: connection.createdAt, - }) - .from(connection) - .where(eq(connection.userId, userId))) as IConnection[]; - - return connections; - } catch (error) { - console.error("Failed to fetch connections:", error); - throw new Error("Failed to fetch connections"); - } -} +import { getAuthenticatedUserId } from '@/app/api/utils'; +import { connection, user } from '@zero/db/schema'; +import { type IConnection } from '@/types'; +import { headers } from 'next/headers'; +import { and, eq } from 'drizzle-orm'; +import { auth } from '@/lib/auth'; +import { db } from '@zero/db'; export async function deleteConnection(connectionId: string) { try { @@ -46,13 +14,13 @@ export async function deleteConnection(connectionId: string) { const session = await auth.api.getSession({ headers: headersList }); if (!session) { - throw new Error("Unauthorized, reconnect"); + throw new Error('Unauthorized, reconnect'); } const userId = session?.user?.id; if (!userId) { - throw new Error("Unauthorized, reconnect"); + throw new Error('Unauthorized, reconnect'); } await db @@ -67,8 +35,8 @@ export async function deleteConnection(connectionId: string) { return { success: true }; } catch (error) { - console.error("Failed to delete connection:", error); - throw new Error("Failed to delete connection"); + console.error('Failed to delete connection:', error); + throw new Error('Failed to delete connection'); } } @@ -78,13 +46,13 @@ export async function putConnection(connectionId: string) { const session = await auth.api.getSession({ headers: headersList }); if (!session) { - throw new Error("Unauthorized, reconnect"); + throw new Error('Unauthorized, reconnect'); } const userId = session?.user?.id; if (!userId) { - throw new Error("Unauthorized, reconnect"); + throw new Error('Unauthorized, reconnect'); } const [foundConnection] = await db @@ -94,7 +62,7 @@ export async function putConnection(connectionId: string) { .limit(1); if (!foundConnection) { - throw new Error("Connection not found"); + throw new Error('Connection not found'); } await db @@ -106,7 +74,7 @@ export async function putConnection(connectionId: string) { return { success: true }; } catch (error) { - console.error("Failed to update connection:", error); - throw new Error("Failed to update connection"); + console.error('Failed to update connection:', error); + throw new Error('Failed to update connection'); } } diff --git a/apps/mail/actions/mail.ts b/apps/mail/actions/mail.ts index 0fc45fc780..3da50f0680 100644 --- a/apps/mail/actions/mail.ts +++ b/apps/mail/actions/mail.ts @@ -1,5 +1,6 @@ 'use server'; import { deleteActiveConnection, FatalErrors, getActiveDriver } from './utils'; +import { IGetThreadResponse } from '@/app/api/driver/types'; import { ParsedMessage } from '@/types'; export const getMails = async ({ @@ -29,7 +30,7 @@ export const getMails = async ({ } }; -export const getMail = async ({ id }: { id: string }): Promise => { +export const getMail = async ({ id }: { id: string }): Promise => { if (!id) { throw new Error('Missing required fields'); } @@ -128,17 +129,17 @@ export const toggleStar = async ({ ids }: { ids: string[] }) => { return { success: false, error: 'No thread IDs provided' }; } - const threadResults = await Promise.allSettled( - threadIds.map(id => driver.get(id)) - ); + const threadResults = await Promise.allSettled(threadIds.map((id) => driver.get(id))); let anyStarred = false; let processedThreads = 0; for (const result of threadResults) { - if (result.status === 'fulfilled' && result.value && result.value.length > 0) { + if (result.status === 'fulfilled' && result.value && result.value.messages.length > 0) { processedThreads++; - const isThreadStarred = result.value.some((message: ParsedMessage) => message.tags?.includes('STARRED')); + const isThreadStarred = result.value.messages.some((message: ParsedMessage) => + message.tags?.includes('STARRED'), + ); if (isThreadStarred) { anyStarred = true; break; diff --git a/apps/mail/actions/notes.ts b/apps/mail/actions/notes.ts index c8ba6d1d96..2eb7e08bde 100644 --- a/apps/mail/actions/notes.ts +++ b/apps/mail/actions/notes.ts @@ -1,7 +1,7 @@ 'use server'; -import { notes } from '@/app/api/notes'; import type { Note } from '@/app/api/notes/types'; +import { notes } from '@/app/api/notes'; export type ActionResult = { success: boolean; @@ -17,7 +17,7 @@ export async function fetchThreadNotes(threadId: string): Promise> + data: Partial>, ): Promise> { try { const result = await notes.updateNote(noteId, data); @@ -56,7 +56,7 @@ export async function updateNote( console.error('Error updating note:', error); return { success: false, - error: error.message || 'Failed to update note' + error: error.message || 'Failed to update note', }; } } @@ -69,13 +69,13 @@ export async function deleteNote(noteId: string): Promise> console.error('Error deleting note:', error); return { success: false, - error: error.message || 'Failed to delete note' + error: error.message || 'Failed to delete note', }; } } export async function reorderNotes( - notesArray: { id: string; order: number; isPinned?: boolean | null }[] + notesArray: { id: string; order: number; isPinned?: boolean | null }[], ): Promise> { try { if (!notesArray || notesArray.length === 0) { @@ -83,8 +83,10 @@ export async function reorderNotes( return { success: true, data: true }; } - console.log(`Reordering ${notesArray.length} notes:`, - notesArray.map(({id, order, isPinned}) => ({id, order, isPinned}))); + console.log( + `Reordering ${notesArray.length} notes:`, + notesArray.map(({ id, order, isPinned }) => ({ id, order, isPinned })), + ); const result = await notes.reorderNotes(notesArray); return { success: true, data: result }; @@ -92,7 +94,7 @@ export async function reorderNotes( console.error('Error reordering notes:', error); return { success: false, - error: error.message || 'Failed to reorder notes' + error: error.message || 'Failed to reorder notes', }; } } diff --git a/apps/mail/actions/settings.ts b/apps/mail/actions/settings.ts index eda4ccaf33..1af30d6b8d 100644 --- a/apps/mail/actions/settings.ts +++ b/apps/mail/actions/settings.ts @@ -1,53 +1,21 @@ -"use server"; - -import { type UserSettings, userSettingsSchema } from "@zero/db/user_settings_default"; -import { earlyAccess, user, userSettings } from "@zero/db/schema"; -import { eq } from "drizzle-orm"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth"; -import { db } from "@zero/db"; -import { Resend } from "resend"; - -async function getAuthenticatedUserId(): Promise { - const headersList = await headers(); - const session = await auth.api.getSession({ headers: headersList }); - - if (!session?.user?.id) { - throw new Error("Unauthorized, please reconnect"); - } - - return session.user.id; -} +'use server'; + +import { type UserSettings, userSettingsSchema } from '@zero/db/user_settings_default'; +import { earlyAccess, user, userSettings } from '@zero/db/schema'; +import { getAuthenticatedUserId } from '@/app/api/utils'; +import { eq } from 'drizzle-orm'; +import { Resend } from 'resend'; +import { db } from '@zero/db'; function validateSettings(settings: unknown): UserSettings { try { return userSettingsSchema.parse(settings); } catch (error) { - console.error("Settings validation error: Schema mismatch", { + console.error('Settings validation error: Schema mismatch', { error, - settings + settings, }); - throw new Error("Invalid settings format"); - } -} - -export async function getUserSettings() { - try { - const userId = await getAuthenticatedUserId(); - - const [result] = await db - .select() - .from(userSettings) - .where(eq(userSettings.userId, userId)) - .limit(1); - - // Returning null here when there are no settings so we can use the default settings with timezone from the browser - if (!result) return null; - - return validateSettings(result.settings); - } catch (error) { - console.error("Failed to fetch user settings:", error); - throw new Error("Failed to fetch user settings"); + throw new Error('Invalid settings format'); } } @@ -83,8 +51,8 @@ export async function saveUserSettings(settings: UserSettings) { return { success: true }; } catch (error) { - console.error("Failed to save user settings:", error); - throw new Error("Failed to save user settings"); + console.error('Failed to save user settings:', error); + throw new Error('Failed to save user settings'); } } @@ -95,7 +63,7 @@ export async function handleGoldenTicket(email: string) { .select({ hasUsedTicket: earlyAccess.hasUsedTicket, email: user.email, - isEarlyAccess: earlyAccess.isEarlyAccess + isEarlyAccess: earlyAccess.isEarlyAccess, }) .from(user) .leftJoin(earlyAccess, eq(user.email, earlyAccess.email)) @@ -121,7 +89,7 @@ export async function handleGoldenTicket(email: string) { subject: 'You <> Zero', text: `Congrats on joining Zero (beta)! Your friend gave you direct access to the beta while skipping the waitlist! You are able to log in now with your email. If you have any questions or need help, please don't hesitate to reach out to us on Discord https://discord.gg/0email.`, }); - } + }; await db .insert(earlyAccess) @@ -132,42 +100,53 @@ export async function handleGoldenTicket(email: string) { updatedAt: new Date(), isEarlyAccess: true, hasUsedTicket: '', - }).catch(async (error) => { + }) + .catch(async (error) => { console.log('Error registering early access', error); if (error.code === '23505') { console.log('Email already registered for early access, granted access'); - await db.update(earlyAccess).set({ - hasUsedTicket: '', - updatedAt: new Date(), - isEarlyAccess: true - }).where(eq(earlyAccess.email, email)) + await db + .update(earlyAccess) + .set({ + hasUsedTicket: '', + updatedAt: new Date(), + isEarlyAccess: true, + }) + .where(eq(earlyAccess.email, email)); } else { console.error('Error registering early access', error); - await db.update(earlyAccess).set({ - hasUsedTicket: email, - updatedAt: new Date() - }).where(eq(earlyAccess.email, foundUser.email)).catch((err) => { - console.error('Error updating early access', err); - }) - await sendNotification() + await db + .update(earlyAccess) + .set({ + hasUsedTicket: email, + updatedAt: new Date(), + }) + .where(eq(earlyAccess.email, foundUser.email)) + .catch((err) => { + console.error('Error updating early access', err); + }); + await sendNotification(); throw error; } - }) + }); - await db.update(earlyAccess).set({ - hasUsedTicket: email, - updatedAt: new Date() - }).where(eq(earlyAccess.email, foundUser.email)) + await db + .update(earlyAccess) + .set({ + hasUsedTicket: email, + updatedAt: new Date(), + }) + .where(eq(earlyAccess.email, foundUser.email)); const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : { emails: { send: async (...args: any[]) => console.log(args) } }; - await sendNotification() + await sendNotification(); return { success: true }; } catch (error) { console.error('Failed to handle golden ticket:', error); throw new Error('Failed to handle golden ticket'); } -} \ No newline at end of file +} diff --git a/apps/mail/app/api/auth/settings/route.ts b/apps/mail/app/api/auth/settings/route.ts index 33a4b88064..c13c84e9de 100644 --- a/apps/mail/app/api/auth/settings/route.ts +++ b/apps/mail/app/api/auth/settings/route.ts @@ -1,24 +1,36 @@ -import { getConnections } from "@/actions/connections"; -import { Ratelimit } from "@upstash/ratelimit"; -import { NextRequest, NextResponse } from "next/server"; -import { processIP, getRatelimitModule, checkRateLimit } from "../../utils"; -import { getUserSettings } from "@/actions/settings"; +import { processIP, getRatelimitModule, checkRateLimit, getAuthenticatedUserId } from '../../utils'; +import { defaultUserSettings, userSettingsSchema } from '@zero/db/user_settings_default'; +import { NextRequest, NextResponse } from 'next/server'; +import { Ratelimit } from '@upstash/ratelimit'; +import { userSettings } from '@zero/db/schema'; +import { eq } from 'drizzle-orm'; +import { db } from '@zero/db'; export const GET = async (req: NextRequest) => { - const finalIp = processIP(req) - const ratelimit = getRatelimitModule({ - prefix: `ratelimit:get-settings`, - limiter: Ratelimit.slidingWindow(60, '1m'), - }) - const { success, headers } = await checkRateLimit(ratelimit, finalIp); - if (!success) { - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers }, - ); - } + const userId = await getAuthenticatedUserId(); + const finalIp = processIP(req); + const ratelimit = getRatelimitModule({ + prefix: `ratelimit:get-settings-${userId}`, + limiter: Ratelimit.slidingWindow(60, '1m'), + }); + const { success, headers } = await checkRateLimit(ratelimit, finalIp); + if (!success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers }, + ); + } - const settings = await getUserSettings(); + const [result] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, userId)) + .limit(1); - return NextResponse.json(settings); -} \ No newline at end of file + // Returning null here when there are no settings so we can use the default settings with timezone from the browser + if (!result) return NextResponse.json({ settings: defaultUserSettings }, { status: 200 }); + + const settings = userSettingsSchema.parse(result.settings); + + return NextResponse.json(settings); +}; diff --git a/apps/mail/app/api/driver/[id]/route.ts b/apps/mail/app/api/driver/[id]/route.ts index 33a275463b..844abfefd7 100644 --- a/apps/mail/app/api/driver/[id]/route.ts +++ b/apps/mail/app/api/driver/[id]/route.ts @@ -1,15 +1,15 @@ +import { checkRateLimit, getRatelimitModule, processIP } from '../../utils'; import { type NextRequest, NextResponse } from 'next/server'; -import { getMail } from '@/actions/mail'; +import { getActiveDriver } from '@/actions/utils'; import { Ratelimit } from '@upstash/ratelimit'; -import { checkRateLimit, getRatelimitModule, processIP } from '../../utils'; - export const GET = async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const finalIp = processIP(req) + const finalIp = processIP(req); + const { id } = await params; const ratelimit = getRatelimitModule({ - prefix: `ratelimit:get-mail`, + prefix: `ratelimit:get-mail-${id}`, limiter: Ratelimit.slidingWindow(60, '1m'), - }) + }); const { success, headers } = await checkRateLimit(ratelimit, finalIp); if (!success) { return NextResponse.json( @@ -17,11 +17,9 @@ export const GET = async (req: NextRequest, { params }: { params: Promise<{ id: { status: 429, headers }, ); } - const { id } = await params; - const threadResponse = await getMail({ - id, - }); + const driver = await getActiveDriver(); + const threadResponse = await driver.get(id); return NextResponse.json(threadResponse, { status: 200, headers, diff --git a/apps/mail/app/api/driver/connections/route.ts b/apps/mail/app/api/driver/connections/route.ts index 52809f0d0a..10328c4caa 100644 --- a/apps/mail/app/api/driver/connections/route.ts +++ b/apps/mail/app/api/driver/connections/route.ts @@ -1,26 +1,39 @@ -import { Ratelimit } from "@upstash/ratelimit"; -import { NextRequest, NextResponse } from "next/server"; -import { processIP, getRatelimitModule, checkRateLimit } from "../../utils"; -import { getConnections } from "@/actions/connections"; +import { processIP, getRatelimitModule, checkRateLimit, getAuthenticatedUserId } from '../../utils'; +import { NextRequest, NextResponse } from 'next/server'; +import { Ratelimit } from '@upstash/ratelimit'; +import { connection } from '@zero/db/schema'; +import { IConnection } from '@/types'; +import { eq } from 'drizzle-orm'; +import { db } from '@zero/db'; export const GET = async (req: NextRequest) => { - const finalIp = processIP(req) - const ratelimit = getRatelimitModule({ - prefix: `ratelimit:get-mail`, - limiter: Ratelimit.slidingWindow(60, '1m'), - }) - const { success, headers } = await checkRateLimit(ratelimit, finalIp); - if (!success) { - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers }, - ); - } + const userId = await getAuthenticatedUserId(); + const finalIp = processIP(req); + const ratelimit = getRatelimitModule({ + prefix: `ratelimit:get-connections-${userId}`, + limiter: Ratelimit.slidingWindow(60, '1m'), + }); + const { success, headers } = await checkRateLimit(ratelimit, finalIp); + if (!success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers }, + ); + } - const connections = await getConnections(); + const connections = (await db + .select({ + id: connection.id, + email: connection.email, + name: connection.name, + picture: connection.picture, + createdAt: connection.createdAt, + }) + .from(connection) + .where(eq(connection.userId, userId))) as IConnection[]; - return NextResponse.json(connections, { - status: 200, - headers, - }); -} \ No newline at end of file + return NextResponse.json(connections, { + status: 200, + headers, + }); +}; diff --git a/apps/mail/app/api/driver/count/route.ts b/apps/mail/app/api/driver/count/route.ts new file mode 100644 index 0000000000..0cfa05545c --- /dev/null +++ b/apps/mail/app/api/driver/count/route.ts @@ -0,0 +1,26 @@ +import { checkRateLimit, getAuthenticatedUserId, getRatelimitModule, processIP } from '../../utils'; +import { type NextRequest, NextResponse } from 'next/server'; +import { getActiveDriver } from '@/actions/utils'; +import { Ratelimit } from '@upstash/ratelimit'; + +export const GET = async (req: NextRequest) => { + const userId = await getAuthenticatedUserId(); + const finalIp = processIP(req); + const ratelimit = getRatelimitModule({ + prefix: `ratelimit:get-count-${userId}`, + limiter: Ratelimit.slidingWindow(60, '1m'), + }); + const { success, headers } = await checkRateLimit(ratelimit, finalIp); + if (!success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers }, + ); + } + const driver = await getActiveDriver(); + const count = await driver.count(); + return NextResponse.json(count, { + status: 200, + headers, + }); +}; diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts index 6e27f242ba..5de81b605b 100644 --- a/apps/mail/app/api/driver/google.ts +++ b/apps/mail/app/api/driver/google.ts @@ -1,10 +1,10 @@ import { parseAddressList, parseFrom, wasSentWithTLS } from '@/lib/email-utils'; +import { IOutgoingMessage, Sender, type ParsedMessage } from '@/types'; import { type IConfig, type MailManager } from './types'; import { type gmail_v1, google } from 'googleapis'; import { EnableBrain } from '@/actions/brain'; -import { IOutgoingMessage, Sender, type ParsedMessage } from '@/types'; -import * as he from 'he'; import { createMimeMessage } from 'mimetext'; +import * as he from 'he'; function fromBase64Url(str: string) { return str.replace(/-/g, '+').replace(/_/g, '/'); @@ -80,7 +80,47 @@ const parseDraft = (draft: gmail_v1.Schema$Draft): ParsedDraft | null => { }; // Helper function for delays -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Exponential backoff helper function +const withExponentialBackoff = async ( + operation: () => Promise, + maxRetries = 3, + initialDelay = 1000, + maxDelay = 10000, +): Promise => { + let retries = 0; + let delayMs = initialDelay; + + while (true) { + try { + return await operation(); + } catch (error: any) { + if (retries >= maxRetries) { + throw error; + } + + // Check if error is rate limit related + const isRateLimit = + error?.code === 429 || + error?.errors?.[0]?.reason === 'rateLimitExceeded' || + error?.errors?.[0]?.reason === 'userRateLimitExceeded'; + + if (!isRateLimit) { + throw error; + } + + console.log( + `Rate limit hit, retrying in ${delayMs}ms (attempt ${retries + 1}/${maxRetries})`, + ); + await delay(delayMs); + + // Exponential backoff with jitter + delayMs = Math.min(delayMs * 2 + Math.random() * 1000, maxDelay); + retries++; + } + } +}; export const driver = async (config: IConfig): Promise => { const auth = new google.auth.OAuth2( @@ -148,11 +188,12 @@ export const driver = async (config: IConfig): Promise => { .map((h) => h.value) .filter((v) => typeof v === 'string') || []; - const cc = ccHeaders.length > 0 - ? ccHeaders - .filter(header => header.trim().length > 0) - .flatMap(header => parseAddressList(header)) - : null; + const cc = + ccHeaders.length > 0 + ? ccHeaders + .filter((header) => header.trim().length > 0) + .flatMap((header) => parseAddressList(header)) + : null; const receivedHeaders = payload?.headers @@ -183,13 +224,21 @@ export const driver = async (config: IConfig): Promise => { messageId, }; }; - const parseOutgoing = async ({ to, subject, message, attachments, headers, cc, bcc }: IOutgoingMessage) => { + const parseOutgoing = async ({ + to, + subject, + message, + attachments, + headers, + cc, + bcc, + }: IOutgoingMessage) => { const msg = createMimeMessage(); const fromEmail = config.auth?.email || 'nobody@example.com'; console.log('Debug - From email:', fromEmail); console.log('Debug - Original to recipients:', JSON.stringify(to, null, 2)); - + msg.setSender({ name: '', addr: fromEmail }); // Track unique recipients to avoid duplicates @@ -207,7 +256,7 @@ export const driver = async (config: IConfig): Promise => { // Handle all To recipients const toRecipients = to - .filter(recipient => { + .filter((recipient) => { if (!recipient || !recipient.email) { console.log('Debug - Skipping invalid recipient:', recipient); return false; @@ -219,9 +268,9 @@ export const driver = async (config: IConfig): Promise => { normalizedEmail: email, fromEmail, isDuplicate: uniqueRecipients.has(email), - isSelf: email === fromEmail + isSelf: email === fromEmail, }); - + // Only check for duplicates, allow sending to yourself if (!uniqueRecipients.has(email)) { uniqueRecipients.add(email); @@ -229,9 +278,9 @@ export const driver = async (config: IConfig): Promise => { } return false; }) - .map(recipient => ({ + .map((recipient) => ({ name: recipient.name || '', - addr: recipient.email + addr: recipient.email, })); console.log('Debug - Filtered to recipients:', JSON.stringify(toRecipients, null, 2)); @@ -242,7 +291,7 @@ export const driver = async (config: IConfig): Promise => { console.error('Debug - No valid recipients after filtering:', { originalTo: to, filteredTo: toRecipients, - fromEmail + fromEmail, }); throw new Error('No valid recipients found in To field'); } @@ -250,7 +299,7 @@ export const driver = async (config: IConfig): Promise => { // Handle CC recipients if (Array.isArray(cc) && cc.length > 0) { const ccRecipients = cc - .filter(recipient => { + .filter((recipient) => { const email = recipient.email.toLowerCase(); if (!uniqueRecipients.has(email) && email !== fromEmail) { uniqueRecipients.add(email); @@ -258,9 +307,9 @@ export const driver = async (config: IConfig): Promise => { } return false; }) - .map(recipient => ({ + .map((recipient) => ({ name: recipient.name || '', - addr: recipient.email + addr: recipient.email, })); if (ccRecipients.length > 0) { @@ -271,7 +320,7 @@ export const driver = async (config: IConfig): Promise => { // Handle BCC recipients if (Array.isArray(bcc) && bcc.length > 0) { const bccRecipients = bcc - .filter(recipient => { + .filter((recipient) => { const email = recipient.email.toLowerCase(); if (!uniqueRecipients.has(email) && email !== fromEmail) { uniqueRecipients.add(email); @@ -279,11 +328,11 @@ export const driver = async (config: IConfig): Promise => { } return false; }) - .map(recipient => ({ + .map((recipient) => ({ name: recipient.name || '', - addr: recipient.email + addr: recipient.email, })); - + if (bccRecipients.length > 0) { msg.setBcc(bccRecipients); } @@ -293,7 +342,7 @@ export const driver = async (config: IConfig): Promise => { msg.addMessage({ contentType: 'text/html', - data: message.trim() + data: message.trim(), }); // Set headers for reply/reply-all/forward @@ -302,12 +351,15 @@ export const driver = async (config: IConfig): Promise => { if (value) { // Ensure References header includes all previous message IDs if (key.toLowerCase() === 'references' && value) { - const refs = value.split(' ').filter(Boolean).map(ref => { - // Add angle brackets if not present - if (!ref.startsWith('<')) ref = `<${ref}`; - if (!ref.endsWith('>')) ref = `${ref}>`; - return ref; - }); + const refs = value + .split(' ') + .filter(Boolean) + .map((ref) => { + // Add angle brackets if not present + if (!ref.startsWith('<')) ref = `<${ref}`; + if (!ref.endsWith('>')) ref = `${ref}>`; + return ref; + }); msg.setHeader(key, refs.join(' ')); } else { msg.setHeader(key, value); @@ -321,23 +373,23 @@ export const driver = async (config: IConfig): Promise => { for (const file of attachments) { const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const base64Content = buffer.toString("base64"); + const base64Content = buffer.toString('base64'); msg.addAttachment({ filename: file.name, - contentType: file.type || "application/octet-stream", - data: base64Content + contentType: file.type || 'application/octet-stream', + data: base64Content, }); } } const emailContent = msg.asRaw(); - const encodedMessage = Buffer.from(emailContent).toString("base64"); + const encodedMessage = Buffer.from(emailContent).toString('base64'); return { raw: encodedMessage, - } - } + }; + }; const normalizeSearch = (folder: string, q: string) => { // Handle special folders if (folder === 'bin') { @@ -355,20 +407,20 @@ export const driver = async (config: IConfig): Promise => { const gmail = google.gmail({ version: 'v1', auth }); const modifyThreadLabels = async ( - threadIds: string[], - requestBody: gmail_v1.Schema$ModifyThreadRequest + threadIds: string[], + requestBody: gmail_v1.Schema$ModifyThreadRequest, ) => { - if (threadIds.length === 0) { - return; + if (threadIds.length === 0) { + return; } - const chunkSize = 15; + const chunkSize = 15; const delayBetweenChunks = 100; const allResults = []; for (let i = 0; i < threadIds.length; i += chunkSize) { const chunk = threadIds.slice(i, i + chunkSize); - + const promises = chunk.map(async (threadId) => { try { const response = await gmail.users.threads.modify({ @@ -377,7 +429,7 @@ export const driver = async (config: IConfig): Promise => { requestBody: requestBody, }); return { threadId, status: 'fulfilled' as const, value: response.data }; - } catch (error: any) { + } catch (error: any) { const errorMessage = error?.errors?.[0]?.message || error.message || error; console.error(`Failed bulk modify operation for thread ${threadId}:`, errorMessage); return { threadId, status: 'rejected' as const, reason: { error: errorMessage } }; @@ -392,10 +444,13 @@ export const driver = async (config: IConfig): Promise => { } } - const failures = allResults.filter(result => result.status === 'rejected'); + const failures = allResults.filter((result) => result.status === 'rejected'); if (failures.length > 0) { - const failureReasons = failures.map(f => ({ threadId: f.threadId, reason: f.reason })); - console.error(`Failed bulk modify operation for ${failures.length}/${threadIds.length} threads:`, failureReasons); + const failureReasons = failures.map((f) => ({ threadId: f.threadId, reason: f.reason })); + console.error( + `Failed bulk modify operation for ${failures.length}/${threadIds.length} threads:`, + failureReasons, + ); } }; @@ -481,197 +536,193 @@ export const driver = async (config: IConfig): Promise => { const labelIds = [..._labelIds]; if (normalizedFolder) labelIds.push(normalizedFolder.toUpperCase()); - const res = await gmail.users.threads.list({ - userId: 'me', - q: normalizedQ ? normalizedQ : undefined, - labelIds: folder === 'inbox' ? labelIds : [], - maxResults, - pageToken: pageToken ? pageToken : undefined, + return withExponentialBackoff(async () => { + const res = await gmail.users.threads.list({ + userId: 'me', + q: normalizedQ ? normalizedQ : undefined, + labelIds: folder === 'inbox' ? labelIds : [], + maxResults, + pageToken: pageToken ? pageToken : undefined, + quotaUser: config.auth?.email, + }); + return { ...res.data, threads: res.data.threads } as any; }); - const threads = await Promise.all( - (res.data.threads || []) - .map(async (thread) => { - if (!thread.id) return null; - const msg = await gmail.users.threads.get({ - userId: 'me', - id: thread.id, - format: 'metadata', - metadataHeaders: ['From', 'Subject', 'Date'], - }); - const labelIds = [ - ...new Set(msg.data.messages?.flatMap((message) => message.labelIds || [])), - ]; - const latestMessage = msg.data.messages?.reverse()?.find((msg) => { - const parsedMessage = parse({ ...msg, labelIds }); - return parsedMessage.sender.email !== config.auth?.email - }) - const message = latestMessage ? latestMessage : msg.data.messages?.[0] - const parsed = parse({ ...message, labelIds }); - return { - ...parsed, - body: '', - processedHtml: '', - blobUrl: '', - totalReplies: msg.data.messages?.length || 0, - threadId: thread.id, - }; - }) - .filter((msg): msg is NonNullable => msg !== null), - ); - - return { ...res.data, threads } as any; }, - get: async (id: string): Promise => { - console.log('Fetching thread:', id); - const res = await gmail.users.threads.get({ userId: 'me', id, format: 'full' }); - if (!res.data.messages) return []; - - const messages = await Promise.all( - res.data.messages.map(async (message) => { - const bodyData = - message.payload?.body?.data || - (message.payload?.parts ? findHtmlBody(message.payload.parts) : '') || - message.payload?.parts?.[0]?.body?.data || - ''; - - if (!bodyData) { - console.log('⚠️ Driver: No email body data found'); - } else { - console.log('✓ Driver: Found email body data'); - } + get: async (id: string) => { + return withExponentialBackoff(async () => { + const res = await gmail.users.threads.get({ + userId: 'me', + id, + format: 'full', + quotaUser: config.auth?.email, + }); + if (!res.data.messages) + return { messages: [], latest: undefined, hasUnread: false, totalReplies: 0 }; + let hasUnread = false; + const messages: ParsedMessage[] = await Promise.all( + res.data.messages.map(async (message) => { + const bodyData = + message.payload?.body?.data || + (message.payload?.parts ? findHtmlBody(message.payload.parts) : '') || + message.payload?.parts?.[0]?.body?.data || + ''; + + if (!bodyData) { + console.log('⚠️ Driver: No email body data found'); + } else { + console.log('✓ Driver: Found email body data'); + } - console.log('🔄 Driver: Processing email body...'); - const decodedBody = bodyData ? fromBinary(bodyData) : ''; + console.log('🔄 Driver: Processing email body...'); + const decodedBody = bodyData ? fromBinary(bodyData) : ''; - // Process inline images if present - let processedBody = decodedBody; - if (message.payload?.parts) { - const inlineImages = message.payload.parts - .filter(part => { - const contentDisposition = part.headers?.find(h => h.name?.toLowerCase() === 'content-disposition')?.value || ''; + // Process inline images if present + let processedBody = decodedBody; + if (message.payload?.parts) { + const inlineImages = message.payload.parts.filter((part) => { + const contentDisposition = + part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition') + ?.value || ''; const isInline = contentDisposition.toLowerCase().includes('inline'); - const hasContentId = part.headers?.some(h => h.name?.toLowerCase() === 'content-id'); + const hasContentId = part.headers?.some( + (h) => h.name?.toLowerCase() === 'content-id', + ); return isInline && hasContentId; }); - for (const part of inlineImages) { - const contentId = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value; - if (contentId && part.body?.attachmentId) { - try { - const imageData = await manager.getAttachment(message.id!, part.body.attachmentId); - if (imageData) { - // Remove < and > from Content-ID if present - const cleanContentId = contentId.replace(/[<>]/g, ''); - - const escapedContentId = cleanContentId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Replace cid: URL with data URL - processedBody = processedBody.replace( - new RegExp(`cid:${escapedContentId}`, 'g'), - `data:${part.mimeType};base64,${imageData}` + for (const part of inlineImages) { + const contentId = part.headers?.find( + (h) => h.name?.toLowerCase() === 'content-id', + )?.value; + if (contentId && part.body?.attachmentId) { + try { + const imageData = await manager.getAttachment( + message.id!, + part.body.attachmentId, ); + if (imageData) { + // Remove < and > from Content-ID if present + const cleanContentId = contentId.replace(/[<>]/g, ''); + + const escapedContentId = cleanContentId.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', + ); + // Replace cid: URL with data URL + processedBody = processedBody.replace( + new RegExp(`cid:${escapedContentId}`, 'g'), + `data:${part.mimeType};base64,${imageData}`, + ); + } + } catch (error) { + console.error('Failed to process inline image:', error); } - } catch (error) { - console.error('Failed to process inline image:', error); } } } - } - - console.log('✅ Driver: Email processing complete', { - hasBody: !!bodyData, - decodedBodyLength: processedBody.length, - }); - const parsedData = parse(message); + console.log('✅ Driver: Email processing complete', { + hasBody: !!bodyData, + decodedBodyLength: processedBody.length, + }); - const attachments = await Promise.all( - message.payload?.parts - ?.filter((part) => { - if (!part.filename || part.filename.length === 0) return false; - - const contentDisposition = part.headers?.find(h => h.name?.toLowerCase() === 'content-disposition')?.value || ''; - const isInline = contentDisposition.toLowerCase().includes('inline'); - - const hasContentId = part.headers?.some(h => h.name?.toLowerCase() === 'content-id'); - - return !isInline || (isInline && !hasContentId); - }) - ?.map(async (part) => { - console.log('Processing attachment:', part.filename); - const attachmentId = part.body?.attachmentId; - if (!attachmentId) { - console.log('No attachment ID found for', part.filename); - return null; - } + const parsedData = parse(message); + + const attachments = await Promise.all( + message.payload?.parts + ?.filter((part) => { + if (!part.filename || part.filename.length === 0) return false; + + const contentDisposition = + part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition') + ?.value || ''; + const isInline = contentDisposition.toLowerCase().includes('inline'); + + const hasContentId = part.headers?.some( + (h) => h.name?.toLowerCase() === 'content-id', + ); + + return !isInline || (isInline && !hasContentId); + }) + ?.map(async (part) => { + console.log('Processing attachment:', part.filename); + const attachmentId = part.body?.attachmentId; + if (!attachmentId) { + console.log('No attachment ID found for', part.filename); + return null; + } - try { - if (!message.id) { - console.error('No message ID found for attachment'); + try { + if (!message.id) { + console.error('No message ID found for attachment'); + return null; + } + const attachmentData = await manager.getAttachment(message.id, attachmentId); + console.log('Fetched attachment data:', { + filename: part.filename, + mimeType: part.mimeType, + size: part.body?.size, + dataLength: attachmentData?.length || 0, + hasData: !!attachmentData, + }); + return { + filename: part.filename || '', + mimeType: part.mimeType || '', + size: Number(part.body?.size || 0), + attachmentId: attachmentId, + headers: part.headers || [], + body: attachmentData ?? '', + }; + } catch (error) { + console.error('Failed to fetch attachment:', part.filename, error); return null; } - const attachmentData = await manager.getAttachment(message.id, attachmentId); - console.log('Fetched attachment data:', { - filename: part.filename, - mimeType: part.mimeType, - size: part.body?.size, - dataLength: attachmentData?.length || 0, - hasData: !!attachmentData, - }); - return { - filename: part.filename || '', - mimeType: part.mimeType || '', - size: Number(part.body?.size || 0), - attachmentId: attachmentId, - headers: part.headers || [], - body: attachmentData ?? '', - }; - } catch (error) { - console.error('Failed to fetch attachment:', part.filename, error); - return null; - } - }) || [], - ).then((attachments) => - attachments.filter((a): a is NonNullable => a !== null), - ); - - const fullEmailData = { - ...parsedData, - body: '', - processedHtml: '', - blobUrl: '', - decodedBody: processedBody, - attachments, - }; + }) || [], + ).then((attachments) => + attachments.filter((a): a is NonNullable => a !== null), + ); - console.log('📧 Driver: Returning email data', { - id: fullEmailData.id, - hasBody: !!fullEmailData.body, - hasBlobUrl: !!fullEmailData.blobUrl, - blobUrlLength: fullEmailData.blobUrl.length, - labels: fullEmailData.tags, - }); + const fullEmailData = { + ...parsedData, + body: '', + processedHtml: '', + blobUrl: '', + decodedBody: processedBody, + attachments, + }; - return fullEmailData; - }), - ); - return messages; + console.log('📧 Driver: Returning email data', { + id: fullEmailData.id, + hasBody: !!fullEmailData.body, + hasBlobUrl: !!fullEmailData.blobUrl, + blobUrlLength: fullEmailData.blobUrl.length, + labels: fullEmailData.tags, + }); + + if (fullEmailData.unread) hasUnread = true; + + return fullEmailData; + }), + ); + return { messages, latest: messages[0], hasUnread, totalReplies: messages.length }; + }); }, create: async (data) => { - const { raw } = await parseOutgoing(data) + const { raw } = await parseOutgoing(data); console.log('Debug - Sending message with threading info:', { threadId: data.threadId, - headers: data.headers + headers: data.headers, }); const res = await gmail.users.messages.send({ userId: 'me', requestBody: { raw, - threadId: data.threadId - } + threadId: data.threadId, + }, }); console.log('Debug - Message sent successfully:', { messageId: res.data.id, - threadId: res.data.threadId + threadId: res.data.threadId, }); return res.data; }, @@ -685,7 +736,10 @@ export const driver = async (config: IConfig): Promise => { ); return { threadIds }; }, - modifyLabels: async (threadIds: string[], options: { addLabels: string[]; removeLabels: string[] }) => { + modifyLabels: async ( + threadIds: string[], + options: { addLabels: string[]; removeLabels: string[] }, + ) => { await modifyThreadLabels(threadIds, { addLabelIds: options.addLabels, removeLabelIds: options.removeLabels, @@ -740,11 +794,11 @@ export const driver = async (config: IConfig): Promise => { console.log(`Fetched draft ${draft.id}:`, msg.data); const message = msg.data.message; if (!message) return null; - + const parsed = parse(message as any); const headers = message.payload?.headers || []; - const date = headers.find(h => h.name?.toLowerCase() === 'date')?.value; - + const date = headers.find((h) => h.name?.toLowerCase() === 'date')?.value; + return { ...parsed, id: draft.id, diff --git a/apps/mail/app/api/driver/notes/route.ts b/apps/mail/app/api/driver/notes/route.ts index 93544b29a1..914b239ee3 100644 --- a/apps/mail/app/api/driver/notes/route.ts +++ b/apps/mail/app/api/driver/notes/route.ts @@ -1,31 +1,33 @@ -import { Ratelimit } from "@upstash/ratelimit"; -import { NextRequest, NextResponse } from "next/server"; -import { processIP, getRatelimitModule, checkRateLimit } from "../../utils"; -import { fetchThreadNotes } from "@/actions/notes"; +import { processIP, getRatelimitModule, checkRateLimit, getAuthenticatedUserId } from '../../utils'; +import { NextRequest, NextResponse } from 'next/server'; +import { fetchThreadNotes } from '@/actions/notes'; +import { Ratelimit } from '@upstash/ratelimit'; +import { notesManager } from '../../notes/db'; export const GET = async (req: NextRequest) => { - const finalIp = processIP(req) - const ratelimit = getRatelimitModule({ - prefix: `ratelimit:get-thread-notes`, - limiter: Ratelimit.slidingWindow(60, '1m'), - }) - const { success, headers } = await checkRateLimit(ratelimit, finalIp); - if (!success) { - return NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers }, - ); - } - const searchParams = req.nextUrl.searchParams; + const userId = await getAuthenticatedUserId(); + const finalIp = processIP(req); + const ratelimit = getRatelimitModule({ + prefix: `ratelimit:get-thread-notes-${userId}`, + limiter: Ratelimit.slidingWindow(60, '1m'), + }); + const { success, headers } = await checkRateLimit(ratelimit, finalIp); + if (!success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers }, + ); + } + const searchParams = req.nextUrl.searchParams; - if (!searchParams.get('threadId')) { - return NextResponse.json({ error: 'Missing threadId' }, { status: 400 }); - } + if (!searchParams.get('threadId')) { + return NextResponse.json({ error: 'Missing threadId' }, { status: 400 }); + } - const notes = await fetchThreadNotes(searchParams.get('threadId')!); + const notes = await notesManager.getThreadNotes(userId, searchParams.get('threadId')!); - return NextResponse.json(notes, { - status: 200, - headers, - }); -} \ No newline at end of file + return NextResponse.json(notes, { + status: 200, + headers, + }); +}; diff --git a/apps/mail/app/api/driver/route.ts b/apps/mail/app/api/driver/route.ts index 02709f9b5b..deeeeaa91d 100644 --- a/apps/mail/app/api/driver/route.ts +++ b/apps/mail/app/api/driver/route.ts @@ -1,15 +1,23 @@ +import { checkRateLimit, getAuthenticatedUserId, getRatelimitModule, processIP } from '../utils'; import { type NextRequest, NextResponse } from 'next/server'; +import { getActiveDriver } from '@/actions/utils'; import { Ratelimit } from '@upstash/ratelimit'; import { defaultPageSize } from '@/lib/utils'; -import { getMails } from '@/actions/mail'; -import { checkRateLimit, getRatelimitModule, processIP } from '../utils'; export const GET = async (req: NextRequest) => { - const finalIp = processIP(req) + const userId = await getAuthenticatedUserId(); + const finalIp = processIP(req); + const searchParams = req.nextUrl.searchParams; + let [folder, pageToken, q, max] = [ + searchParams.get('folder'), + searchParams.get('pageToken'), + searchParams.get('q'), + Number(searchParams.get('max')), + ]; const ratelimit = getRatelimitModule({ - prefix: `ratelimit:list-threads`, + prefix: `ratelimit:list-threads-${folder}-${userId}`, limiter: Ratelimit.slidingWindow(60, '1m'), - }) + }); const { success, headers } = await checkRateLimit(ratelimit, finalIp); if (!success) { return NextResponse.json( @@ -17,24 +25,12 @@ export const GET = async (req: NextRequest) => { { status: 429, headers }, ); } - const searchParams = req.nextUrl.searchParams; - let [folder, pageToken, q, max] = [ - searchParams.get('folder'), - searchParams.get('pageToken'), - searchParams.get('q'), - Number(searchParams.get('max')), - ]; if (!folder) folder = 'inbox'; if (!pageToken) pageToken = ''; if (!q) q = ''; if (!max) max = defaultPageSize; - const threadsResponse = await getMails({ - folder, - q, - max, - pageToken, - labelIds: undefined, - }); + const driver = await getActiveDriver(); + const threadsResponse = await driver.list(folder, q, max, undefined, pageToken); return NextResponse.json(threadsResponse, { status: 200, headers, diff --git a/apps/mail/app/api/driver/types.ts b/apps/mail/app/api/driver/types.ts index f3129ad764..6cb1bbce26 100644 --- a/apps/mail/app/api/driver/types.ts +++ b/apps/mail/app/api/driver/types.ts @@ -1,7 +1,14 @@ import { type IOutgoingMessage, type InitialThread, type ParsedMessage } from '@/types'; +export interface IGetThreadResponse { + messages: ParsedMessage[]; + latest: ParsedMessage | undefined; + hasUnread: boolean; + totalReplies: number; +} + export interface MailManager { - get(id: string): Promise; + get(id: string): Promise; create(data: IOutgoingMessage): Promise; createDraft(data: any): Promise; getDraft: (id: string) => Promise; @@ -35,6 +42,6 @@ export interface IConfig { auth?: { access_token: string; refresh_token: string; - email: string + email: string; }; } diff --git a/apps/mail/app/api/utils.ts b/apps/mail/app/api/utils.ts index e9485763c2..7aecaf9e49 100644 --- a/apps/mail/app/api/utils.ts +++ b/apps/mail/app/api/utils.ts @@ -1,40 +1,52 @@ import { Ratelimit, Algorithm, RatelimitConfig } from '@upstash/ratelimit'; -import { redis } from '@/lib/redis'; import { NextRequest } from 'next/server'; +import { headers } from 'next/headers'; +import { redis } from '@/lib/redis'; +import { auth } from '@/lib/auth'; export const getRatelimitModule = (config: { - limiter: RatelimitConfig['limiter']; - prefix: RatelimitConfig['prefix'] + limiter: RatelimitConfig['limiter']; + prefix: RatelimitConfig['prefix']; }) => { - const ratelimit = new Ratelimit({ - redis, - limiter: config.limiter, - analytics: true, - prefix: config.prefix, - }); + const ratelimit = new Ratelimit({ + redis, + limiter: config.limiter, + analytics: true, + prefix: config.prefix, + }); + + return ratelimit; +}; + +export async function getAuthenticatedUserId(): Promise { + const headersList = await headers(); + const session = await auth.api.getSession({ headers: headersList }); - return ratelimit + if (!session?.user?.id) { + throw new Error('Unauthorized, please reconnect'); + } + + return session.user.id; } export const checkRateLimit = async (ratelimit: Ratelimit, finalIp: string) => { - const { success, limit, reset, remaining } = await ratelimit.limit(finalIp); - const headers = { - 'X-RateLimit-Limit': limit.toString(), - 'X-RateLimit-Remaining': remaining.toString(), - 'X-RateLimit-Reset': reset.toString(), - }; - if (!success) - console.log(`Rate limit exceeded for IP ${finalIp}.`); - return { success, headers }; -} + const { success, limit, reset, remaining } = await ratelimit.limit(finalIp); + const headers = { + 'X-RateLimit-Limit': limit.toString(), + 'X-RateLimit-Remaining': remaining.toString(), + 'X-RateLimit-Reset': reset.toString(), + }; + if (!success) console.log(`Rate limit exceeded for IP ${finalIp}.`); + return { success, headers }; +}; export const processIP = (req: NextRequest) => { - const cfIP = req.headers.get('CF-Connecting-IP'); - const ip = req.headers.get('x-forwarded-for'); - if (!ip && !cfIP && process.env.NODE_ENV === 'production') { - console.log('No IP detected'); - throw new Error('No IP detected'); - } - const cleanIp = ip?.split(',')[0]?.trim() ?? null; - return cfIP ?? cleanIp ?? '127.0.0.1'; -} + const cfIP = req.headers.get('CF-Connecting-IP'); + const ip = req.headers.get('x-forwarded-for'); + if (!ip && !cfIP && process.env.NODE_ENV === 'production') { + console.log('No IP detected'); + throw new Error('No IP detected'); + } + const cleanIp = ip?.split(',')[0]?.trim() ?? null; + return cfIP ?? cleanIp ?? '127.0.0.1'; +}; diff --git a/apps/mail/components/create/ai-assistant.tsx b/apps/mail/components/create/ai-assistant.tsx index a89b44f9d6..27ebf65db1 100644 --- a/apps/mail/components/create/ai-assistant.tsx +++ b/apps/mail/components/create/ai-assistant.tsx @@ -9,6 +9,7 @@ import { useSession } from '@/lib/auth-client'; import { Input } from '@/components/ui/input'; import { type JSONContent } from 'novel'; import { toast } from 'sonner'; +import posthog from 'posthog-js'; // Types interface AIAssistantProps { @@ -285,6 +286,9 @@ export const AIAssistant = ({ try { setIsLoading(true); + // Track AI assistant usage + posthog.capture('Create Email AI Assistant Submit'); + // Add user message addMessage('user', prompt, 'question'); @@ -352,6 +356,9 @@ export const AIAssistant = ({ onContentGenerated(generatedContent.jsonContent); } + // Track AI assistant usage + posthog.capture('Create Email AI Assistant Submit'); + // Add confirmation message addMessage('system', 'Email content applied successfully.', 'system'); resetStates(); diff --git a/apps/mail/components/create/create-email.tsx b/apps/mail/components/create/create-email.tsx index cd6d765a9c..a91396644b 100644 --- a/apps/mail/components/create/create-email.tsx +++ b/apps/mail/components/create/create-email.tsx @@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator'; import { SidebarToggle } from '../ui/sidebar-toggle'; import Paragraph from '@tiptap/extension-paragraph'; import { useSettings } from '@/hooks/use-settings'; +import { useContacts } from '@/hooks/use-contacts'; import Document from '@tiptap/extension-document'; import { Button } from '@/components/ui/button'; import { useSession } from '@/lib/auth-client'; @@ -26,6 +27,13 @@ import { toast } from 'sonner'; import * as React from 'react'; import Editor from './editor'; import './prosemirror.css'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import posthog from 'posthog-js'; const MAX_VISIBLE_ATTACHMENTS = 12; @@ -49,6 +57,17 @@ const createEmptyDocContent = (): JSONContent => ({ ], }); +const filterContacts = (contacts: any[], searchTerm: string, excludeEmails: string[]) => { + if (!searchTerm) return []; + const term = searchTerm.toLowerCase(); + return contacts.filter( + (contact) => + (contact.email?.toLowerCase().includes(term) || + contact.name?.toLowerCase().includes(term)) && + !excludeEmails.includes(contact.email) + ); +}; + export function CreateEmail({ initialTo = '', initialSubject = '', @@ -58,8 +77,12 @@ export function CreateEmail({ initialSubject?: string; initialBody?: string; }) { - const [toInput, setToInput] = React.useState(''); const [toEmails, setToEmails] = React.useState(initialTo ? [initialTo] : []); + const [toInput, setToInput] = React.useState(''); + const [selectedContactIndex, setSelectedContactIndex] = React.useState(0); + const [selectedCcContactIndex, setSelectedCcContactIndex] = React.useState(0); + const [selectedBccContactIndex, setSelectedBccContactIndex] = React.useState(0); + const contacts = useContacts(); const [ccInput, setCcInput] = React.useState(''); const [ccEmails, setCcEmails] = React.useState([]); const [bccInput, setBccInput] = React.useState(''); @@ -76,6 +99,8 @@ export function CreateEmail({ const [draftId, setDraftId] = useQueryState('draftId'); const [includeSignature, setIncludeSignature] = React.useState(true); const { settings } = useSettings(); + const [isCardHovered, setIsCardHovered] = React.useState(false); + const dragCounter = React.useRef(0); const [defaultValue, setDefaultValue] = React.useState(() => { if (initialBody) { @@ -102,6 +127,21 @@ export function CreateEmail({ const userEmail = activeAccount?.email || session?.activeConnection?.email || session?.user?.email || ''; + const filteredContacts = React.useMemo( + () => filterContacts(contacts, toInput, toEmails), + [contacts, toInput, toEmails] + ); + + const filteredCcContacts = React.useMemo( + () => filterContacts(contacts, ccInput, [...toEmails, ...ccEmails]), + [contacts, ccInput, toEmails, ccEmails] + ); + + const filteredBccContacts = React.useMemo( + () => filterContacts(contacts, bccInput, [...toEmails, ...ccEmails, ...bccEmails]), + [contacts, bccInput, toEmails, ccEmails, bccEmails] + ); + React.useEffect(() => { if (!draftId && !defaultValue) { setDefaultValue(createEmptyDocContent()); @@ -159,7 +199,7 @@ export function CreateEmail({ const ccInputRef = React.useRef(null); const bccInputRef = React.useRef(null); const subjectInputRef = React.useRef(null); - + const fileInputRef = React.useRef(null); // Remove auto-focus logic React.useEffect(() => { @@ -181,7 +221,7 @@ export function CreateEmail({ }, []); const handleEmailInputChange = (type: 'to' | 'cc' | 'bcc', value: string) => { - // Just update the input value, no validation or checks + // Update the input value immediately without any validation switch (type) { case 'to': setToInput(value); @@ -208,7 +248,8 @@ export function CreateEmail({ setEmailState([...emailState, trimmedEmail]); setInputState(''); setHasUnsavedChanges(true); - } else { + } else if (emailState.length === 0) { + // Only show error if there are no emails yet toast.error(t('pages.createEmail.invalidEmail')); } }; @@ -287,6 +328,17 @@ export function CreateEmail({ attachments: attachments, }); + // Track different email sending scenarios + if (showCc && showBcc) { + console.log(posthog.capture('Create Email Sent with CC and BCC')); + } else if (showCc) { + console.log(posthog.capture('Create Email Sent with CC')); + } else if (showBcc) { + console.log(posthog.capture('Create Email Sent with BCC')); + } else { + console.log(posthog.capture('Create Email Sent')); + } + setIsLoading(false); toast.success(t('pages.createEmail.emailSentSuccessfully')); @@ -345,7 +397,7 @@ export function CreateEmail({ // Add a mount ref to ensure we only auto-focus once const isFirstMount = React.useRef(true); - const handleAttachment = async (e: React.ChangeEvent) => { + const handleAttachment = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { setAttachments((prev) => [...prev, ...Array.from(files)]); @@ -358,6 +410,21 @@ export function CreateEmail({ setHasUnsavedChanges(true); }; + const handleDragEnterCard = (e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current += 1; + setIsCardHovered(true); + }; + + const handleDragLeaveCard = (e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current -= 1; + if (dragCounter.current <= 0) { + setIsCardHovered(false); + dragCounter.current = 0; + } + }; + React.useEffect(() => { if (initialTo) { const emails = initialTo.split(',').map((email) => email.trim()); @@ -392,6 +459,41 @@ export function CreateEmail({ } }, [initialTo, initialSubject, initialBody, defaultValue]); + const toDropdownRef = React.useRef(null); + const ccDropdownRef = React.useRef(null); + const bccDropdownRef = React.useRef(null); + + // Add this effect to handle scrolling + React.useEffect(() => { + const dropdownRef = toDropdownRef.current; + if (dropdownRef && selectedContactIndex >= 0) { + const selectedItem = dropdownRef.children[selectedContactIndex] as HTMLElement; + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + }, [selectedContactIndex]); + + React.useEffect(() => { + const dropdownRef = ccDropdownRef.current; + if (dropdownRef && selectedCcContactIndex >= 0) { + const selectedItem = dropdownRef.children[selectedCcContactIndex] as HTMLElement; + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + }, [selectedCcContactIndex]); + + React.useEffect(() => { + const dropdownRef = bccDropdownRef.current; + if (dropdownRef && selectedBccContactIndex >= 0) { + const selectedItem = dropdownRef.children[selectedBccContactIndex] as HTMLElement; + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + }, [selectedBccContactIndex]); + return (
- {isDragging && ( -
-
- -

{t('pages.createEmail.dropFilesToAttach')}

-
-
- )}
-
-
-
-
-
- {t('common.mailDisplay.to')} -
-
- {toEmails.map((email, index) => ( -
- - {email} - - -
- ))} - handleEmailInputChange('to', e.target.value)} - onBlur={() => handleAddEmail('to', toInput)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleAddEmail('to', toInput); - } - }} - /> -
- - -
+
+
+ {isDragging && isCardHovered && ( +
+
+ +

{t('pages.createEmail.dropFilesToAttach')}

- - {showCc && ( + )} +
+
- Cc + {t('common.mailDisplay.to')}
- {ccEmails.map((email, index) => ( + {toEmails.map((email, index) => (
{ - setCcEmails((emails) => emails.filter((_, i) => i !== index)); + setToEmails((emails) => emails.filter((_, i) => i !== index)); setHasUnsavedChanges(true); }} > @@ -520,264 +544,463 @@ export function CreateEmail({
))} +
handleEmailInputChange('cc', e.target.value)} + className="text-md relative left-[3px] w-full min-w-[120px] bg-transparent placeholder:text-[#616161] placeholder:opacity-50 focus:outline-none" + placeholder={toEmails.length ? '' : t('pages.createEmail.example')} + value={toInput} + onChange={(e) => handleEmailInputChange('to', e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { + if (e.key === 'Enter') { e.preventDefault(); - handleAddEmail('cc', ccInput); + if (filteredContacts.length > 0) { + const selectedEmail = filteredContacts[selectedContactIndex]?.email; + if (selectedEmail) handleAddEmail('to', selectedEmail); + setSelectedContactIndex(0); + } else { + handleAddEmail('to', toInput); + } + } else if (e.key === 'ArrowDown' && filteredContacts.length > 0) { + e.preventDefault(); + setSelectedContactIndex((prev) => + Math.min(prev + 1, filteredContacts.length - 1), + ); + } else if (e.key === 'ArrowUp' && filteredContacts.length > 0) { + e.preventDefault(); + setSelectedContactIndex((prev) => Math.max(prev - 1, 0)); } }} /> + {toInput && filteredContacts.length > 0 && ( +
+ {filteredContacts.map((contact, index) => ( + + ))} +
+ )} +
+
+ + +
- )} - {showBcc && ( -
-
- Bcc -
-
- {bccEmails.map((email, index) => ( -
- - {email} - - +
+ ))} +
+ { - setBccEmails((emails) => emails.filter((_, i) => i !== index)); - setHasUnsavedChanges(true); + type="text" + className="text-md relative left-[3px] w-full min-w-[120px] bg-transparent placeholder:text-[#616161] placeholder:opacity-50 focus:outline-none" + placeholder={ccEmails.length ? '' : t('pages.createEmail.example')} + value={ccInput} + onChange={(e) => handleEmailInputChange('cc', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (filteredCcContacts.length > 0) { + const selectedEmail = filteredCcContacts[selectedCcContactIndex]?.email; + if (selectedEmail) { + handleAddEmail('cc', selectedEmail); + setSelectedCcContactIndex(0); + } + } else { + handleAddEmail('cc', ccInput); + } + } else if (e.key === 'ArrowDown' && filteredCcContacts.length > 0) { + e.preventDefault(); + setSelectedCcContactIndex((prev) => + Math.min(prev + 1, filteredCcContacts.length - 1), + ); + } else if (e.key === 'ArrowUp' && filteredCcContacts.length > 0) { + e.preventDefault(); + setSelectedCcContactIndex((prev) => Math.max(prev - 1, 0)); + } }} + /> + {ccInput && filteredCcContacts.length > 0 && ( +
+ {filteredCcContacts.map((contact, index) => ( + + ))} +
+ )} +
+
+
+ )} + + {showBcc && ( +
+
+ Bcc +
+
+ {bccEmails.map((email, index) => ( +
- - + + {email} + + +
+ ))} +
+ handleEmailInputChange('bcc', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (filteredBccContacts.length > 0) { + const selectedEmail = filteredBccContacts[selectedBccContactIndex]?.email; + if (selectedEmail) { + handleAddEmail('bcc', selectedEmail); + setSelectedBccContactIndex(0); + } + } else { + handleAddEmail('bcc', bccInput); + } + } else if (e.key === 'ArrowDown' && filteredBccContacts.length > 0) { + e.preventDefault(); + setSelectedBccContactIndex((prev) => + Math.min(prev + 1, filteredBccContacts.length - 1), + ); + } else if (e.key === 'ArrowUp' && filteredBccContacts.length > 0) { + e.preventDefault(); + setSelectedBccContactIndex((prev) => Math.max(prev - 1, 0)); + } + }} + /> + {bccInput && filteredBccContacts.length > 0 && ( +
+ {filteredBccContacts.map((contact, index) => ( + + ))} +
+ )}
- ))} - handleEmailInputChange('bcc', e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleAddEmail('bcc', bccInput); - } - }} - /> +
-
- )} + )} -
-
- {t('common.searchBar.subject')} +
+
+ {t('common.searchBar.subject')} +
+ { + setSubjectInput(e.target.value); + setHasUnsavedChanges(true); + }} + />
- { - setSubjectInput(e.target.value); - setHasUnsavedChanges(true); - }} - /> -
-
-
- {t('pages.createEmail.body')} +
+
+ {t('pages.createEmail.body')} +
+
+ {defaultValue && ( + { + setMessageContent(newContent); + if (newContent.trim() !== '') { + setHasUnsavedChanges(true); + } + }} + key={resetEditorKey} + placeholder={t('pages.createEmail.writeYourMessageHere')} + onAttachmentsChange={setAttachments} + onCommandEnter={handleSendEmail} + /> + )} +
-
- {defaultValue && ( - { - setMessageContent(newContent); - if (newContent.trim() !== '') { - setHasUnsavedChanges(true); - } - }} - key={resetEditorKey} - placeholder={t('pages.createEmail.writeYourMessageHere')} - onAttachmentsChange={setAttachments} - onCommandEnter={handleSendEmail} - /> - )} -
-
-
- { - console.log('CreateEmail: Received AI-generated content', { - jsonContentType: jsonContent.type, - hasContent: Boolean(jsonContent.content), - contentLength: jsonContent.content?.length || 0, - newSubject: newSubject, - }); - - try { - // Update the editor content with the AI-generated content - setDefaultValue(jsonContent); - - // Extract and set the text content for validation purposes - // This ensures the submit button is enabled immediately - if (jsonContent.content && jsonContent.content.length > 0) { - // Extract text content from JSON structure recursively - const extractTextContent = (node: any): string => { - if (!node) return ''; - - if (node.text) return node.text; - - if (node.content && Array.isArray(node.content)) { - return node.content.map(extractTextContent).join(' '); - } - - return ''; - }; - - // Process all content nodes - const textContent = jsonContent.content - .map(extractTextContent) - .join('\n') - .trim(); - setMessageContent(textContent); +
+
+
+
+
+
+ { + console.log('CreateEmail: Received AI-generated content', { + jsonContentType: jsonContent.type, + hasContent: Boolean(jsonContent.content), + contentLength: jsonContent.content?.length || 0, + newSubject: newSubject, + }); + + try { + // Update the editor content with the AI-generated content + setDefaultValue(jsonContent); + + // Extract and set the text content for validation purposes + // This ensures the submit button is enabled immediately + if (jsonContent.content && jsonContent.content.length > 0) { + // Extract text content from JSON structure recursively + const extractTextContent = (node: any): string => { + if (!node) return ''; + + if (node.text) return node.text; + + if (node.content && Array.isArray(node.content)) { + return node.content.map(extractTextContent).join(' '); } - // Update the subject if provided - if (newSubject && (!subjectInput || subjectInput.trim() === '')) { - console.log('CreateEmail: Setting new subject from AI', newSubject); - setSubjectInput(newSubject); - } + return ''; + }; - // Mark as having unsaved changes - setHasUnsavedChanges(true); + // Process all content nodes + const textContent = jsonContent.content + .map(extractTextContent) + .join('\n') + .trim(); + setMessageContent(textContent); + } - // Reset the editor to ensure it picks up the new content - setResetEditorKey((prev) => prev + 1); + // Update the subject if provided + if (newSubject && (!subjectInput || subjectInput.trim() === '')) { + console.log('CreateEmail: Setting new subject from AI', newSubject); + setSubjectInput(newSubject); + } - console.log('CreateEmail: Successfully applied AI content'); - } catch (error) { - console.error('CreateEmail: Error applying AI content', error); - toast.error('Error applying AI content to your email. Please try again.'); - } - }} - /> -
-
-
- {attachments.length > 0 && ( - - - - - -
-
-

- {t('common.replyCompose.attachments')} -

-

- {attachments.length}{' '} - {t('common.replyCompose.fileCount', { count: attachments.length })} -

-
- -
-
- {attachments.map((file, index) => ( -
- -
-

- {truncateFileName(file.name, 20)} -

-

- {(file.size / (1024 * 1024)).toFixed(2)} MB -

-
-
- ))} + // Mark as having unsaved changes + setHasUnsavedChanges(true); + + // Reset the editor to ensure it picks up the new content + setResetEditorKey((prev) => prev + 1); + + console.log('CreateEmail: Successfully applied AI content'); + } catch (error) { + console.error('CreateEmail: Error applying AI content', error); + toast.error( + 'Error applying AI content to your email. Please try again.', + ); + } + }} + /> +
+
+
+ {attachments.length > 0 && ( + + + + + +
+
+

+ {t('common.replyCompose.attachments')} +

+

+ {attachments.length}{' '} + {t('common.replyCompose.fileCount', { + count: attachments.length, + })} +

+
+ +
+
+ {attachments.map((file, index) => ( +
+ +
+

+ {truncateFileName(file.name, 20)} +

+

+ {(file.size / (1024 * 1024)).toFixed(2)} MB +

+
-
+ ))}
- - - )} -
- - -
- -
+
+
+
+
+ )} +
+ +
+
- -
- -
); diff --git a/apps/mail/components/draft/drafts.tsx b/apps/mail/components/draft/drafts.tsx index 28054eafe2..ed902b363d 100644 --- a/apps/mail/components/draft/drafts.tsx +++ b/apps/mail/components/draft/drafts.tsx @@ -74,7 +74,7 @@ export function DraftsLayout() { ) : (
- + {/* */}
)}
diff --git a/apps/mail/components/home/navbar.tsx b/apps/mail/components/home/navbar.tsx index 5a0c396586..28e38a6b25 100644 --- a/apps/mail/components/home/navbar.tsx +++ b/apps/mail/components/home/navbar.tsx @@ -73,7 +73,7 @@ export default function Navbar() { /> {/* Mobile Navigation */} - {/*
+
@@ -106,13 +106,11 @@ export default function Navbar() {
-
*/} +
- {/* <> -
- {desktopNavContent()} -
- */} +
+ {desktopNavContent()} +
); } diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index 4562368914..3c5ce060d5 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -11,7 +11,7 @@ import { User, Users, } from 'lucide-react'; -import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode } from '@/types'; +import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode, ParsedMessage } from '@/types'; import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { EmptyState, type FolderType } from '@/components/mail/empty-state'; @@ -20,7 +20,7 @@ import { useParams, useRouter } from 'next/navigation'; import { cn, FOLDERS, formatDate, getEmailLogo } from '@/lib/utils'; import { Avatar, AvatarImage, AvatarFallback } from '../ui/avatar'; import { useMailNavigation } from '@/hooks/use-mail-navigation'; -import { preloadThread, useThreads } from '@/hooks/use-threads'; +import { preloadThread, useThread, useThreads } from '@/hooks/use-threads'; import { useHotKey, useKeyState } from '@/hooks/use-hot-key'; import { useSearchValue } from '@/hooks/use-search-value'; import { markAsRead, markAsUnread } from '@/actions/mail'; @@ -80,6 +80,7 @@ const Thread = memo( onClick, sessionData, isKeyboardFocused, + demoMessage, }: ConditionalThreadProps) => { const [mail] = useMail(); const [searchValue] = useSearchValue(); @@ -90,17 +91,22 @@ const Thread = memo( const hoverTimeoutRef = useRef | undefined>(undefined); const isHovering = useRef(false); const hasPrefetched = useRef(false); + const {data: getThreadData, isLoading} = useThread(demo ? null : message.id); + + const latestMessage = demo ? demoMessage : getThreadData?.latest; + const isMailSelected = useMemo(() => { - if (!threadId) return false; - const _threadId = message.threadId ?? message.id; + if (!threadId || !latestMessage) return false; + const _threadId = latestMessage.threadId ?? message.id; return _threadId === threadId || threadId === mail.selected; - }, [threadId, message.id, message.threadId, mail.selected]); + }, [threadId, message.id, latestMessage, mail.selected]); - const isMailBulkSelected = mail.bulkSelected.includes(message.threadId ?? message.id); + const isMailBulkSelected = mail.bulkSelected.includes(latestMessage?.threadId ?? message.id); const threadLabels = useMemo(() => { - return [...(message.tags || [])]; - }, [message.tags]); + if (!latestMessage) return []; + return [...(latestMessage.tags || [])]; + }, [latestMessage]); const isFolderInbox = folder === FOLDERS.INBOX || !folder; const isFolderSpam = folder === FOLDERS.SPAM; @@ -108,7 +114,7 @@ const Thread = memo( const isFolderBin = folder === FOLDERS.BIN; const handleMouseEnter = () => { - if (demo) return; + if (demo || !latestMessage) return; isHovering.current = true; // Prefetch only in single select mode @@ -121,7 +127,7 @@ const Thread = memo( // Set new timeout for prefetch hoverTimeoutRef.current = setTimeout(() => { if (isHovering.current) { - const messageId = message.threadId ?? message.id; + const messageId = latestMessage.threadId ?? message.id; // Only prefetch if still hovering and hasn't been prefetched console.log( `🕒 Hover threshold reached for email ${messageId}, initiating prefetch...`, @@ -154,15 +160,17 @@ const Thread = memo( }; }, []); - const demoContent =
+ if (!demo && (isLoading || !latestMessage || !getThreadData)) return null + + const demoContent = demo && latestMessage ?
- {message?.sender?.name[0]?.toUpperCase()} + {latestMessage.sender?.name[0]?.toUpperCase()}
@@ -190,39 +198,39 @@ const Thread = memo(

- {highlightText(message.sender.name, searchValue.highlight)} + {highlightText(latestMessage.sender.name, searchValue.highlight)} {' '} - {message.unread && !isMailSelected ? ( + {latestMessage.unread && !isMailSelected ? ( ) : null}

- {message.totalReplies > 1 ? ( + {Math.random() > 0.5 ? ( - {message.totalReplies} + {Math.random() * 10} - {t('common.mail.replies', { count: message.totalReplies })} + {t('common.mail.replies', { count: Math.random() * 10 })} ) : null}
- {message.receivedOn ? ( + {latestMessage.receivedOn ? (

- {formatDate(message.receivedOn.split('.')[0] || '')} + {formatDate(latestMessage.receivedOn.split('.')[0] || '')}

) : null}
@@ -231,24 +239,26 @@ const Thread = memo( 'mt-1 line-clamp-1 text-xs opacity-70 transition-opacity', )} > - {highlightText(message.subject, searchValue.highlight)} + {highlightText(latestMessage.subject, searchValue.highlight)}

-
+
: null - const content = ( -
+ if (demo) return demoContent + + const content = latestMessage && getThreadData ? ( +
- {message?.sender?.name[0]?.toUpperCase()} + {latestMessage.sender.name[0]?.toUpperCase()}
@@ -276,46 +286,46 @@ const Thread = memo(

- {highlightText(message.sender.name, searchValue.highlight)} + {highlightText(latestMessage.sender.name, searchValue.highlight)} {' '} - {message.unread && !isMailSelected ? ( + {getThreadData.hasUnread && !isMailSelected ? ( ) : null}

- {message.totalReplies > 1 ? ( + {getThreadData.totalReplies > 1 ? ( - {message.totalReplies} + {getThreadData.totalReplies} - {t('common.mail.replies', { count: message.totalReplies })} + {t('common.mail.replies', { count: getThreadData.totalReplies })} ) : null}
- {message.receivedOn ? ( + {latestMessage.receivedOn ? (

- {formatDate(message.receivedOn.split('.')[0] || '')} + {formatDate(latestMessage.receivedOn.split('.')[0] || '')}

) : null}

- {highlightText(message.subject, searchValue.highlight)} + {highlightText(latestMessage.subject, searchValue.highlight)}

@@ -324,12 +334,12 @@ const Thread = memo(
- ); + ) : null; - return demo ? demoContent : ( + return latestMessage ? ( {content} - ); + ) : null; }, ); @@ -363,6 +373,7 @@ export function MailListDemo({ message={item} selectMode={'single'} onClick={(message) => () => onSelectMail && onSelectMail(message)} + demoMessage={item as any} /> ) : null; })} @@ -387,6 +398,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { isLoading, loadMore, mutate, + isReachingEnd } = useThreads(); const allCategories = Categories(); @@ -471,7 +483,8 @@ export const MailList = memo(({ isCompact }: MailListProps) => { } // Otherwise select all items else if (items.length > 0) { - const allIds = items.map((item) => item.threadId ?? item.id); + // TODO: debug + const allIds = items.map((item) => item.id); setMail((prev) => ({ ...prev, bulkSelected: allIds, @@ -563,7 +576,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { }, [isKeyPressed]); const handleMailClick = useCallback( - (message: InitialThread) => () => { + (message: ParsedMessage) => () => { handleMouseEnter(message.id); const messageThreadId = message.threadId ?? message.id; @@ -581,7 +594,6 @@ export const MailList = memo(({ isCompact }: MailListProps) => { [handleMouseEnter, setThreadId, t, setMail], ); - const isEmpty = items.length === 0; const isFiltering = searchValue.value.trim().length > 0; // Add effect to handle search loading state @@ -595,28 +607,6 @@ export const MailList = memo(({ isCompact }: MailListProps) => { } }, [isLoading, isFiltering, setSearchValue]); - if (isEmpty && session) { - if (isFiltering) { - return ( -
- {isLoading || searchValue.isLoading ? ( -
-
-

- {searchValue.isAISearching - ? t('common.searchBar.aiSearching') - : t('common.searchBar.searching')} -

-
- ) : ( - - )} -
- ); - } - return ; - } - return ( <>
{ isCompact={isCompact} sessionData={sessionData} message={data} - key={data.id} + key={`${data.id}-${index}`} isKeyboardFocused={focusedIndex === index && keyboardActive} isInQuickActionMode={isQuickActionMode && focusedIndex === index} selectedQuickActionIndex={quickActionIndex} @@ -640,14 +630,14 @@ export const MailList = memo(({ isCompact }: MailListProps) => { /> ); })} - {items.length >= 9 && nextPageToken && ( + {items.length >= 9 && nextPageToken && !isValidating && (
{threadId ? : null} @@ -435,7 +435,7 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) {
)} - {(emailData || []).map((message, index) => ( + {(emailData.messages || []).map((message, index) => (
))} diff --git a/apps/mail/components/ui/app-sidebar.tsx b/apps/mail/components/ui/app-sidebar.tsx index 4e374c8c2b..a2724e5dae 100644 --- a/apps/mail/components/ui/app-sidebar.tsx +++ b/apps/mail/components/ui/app-sidebar.tsx @@ -59,7 +59,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
@@ -124,7 +124,7 @@ function ComposeButton() { return (
- router.push('/settings/general')}> -
- -

{t('common.actions.settings')}

-
+ + +
+ +

{t('common.actions.settings')}

+
+
@@ -246,7 +266,7 @@ export function NavUser() {

{t('common.actions.logout')}

- + ) : ( <> diff --git a/apps/mail/hooks/use-contacts.ts b/apps/mail/hooks/use-contacts.ts new file mode 100644 index 0000000000..50f5b23dc4 --- /dev/null +++ b/apps/mail/hooks/use-contacts.ts @@ -0,0 +1,37 @@ +import { dexieStorageProvider } from '@/lib/idb'; +import { useSession } from '@/lib/auth-client'; +import useSWRImmutable from 'swr/immutable'; +import { useEffect, useState } from 'react'; +import { Sender } from '@/types'; + +export const useContacts = () => { + const { data: session } = useSession(); + const { mutate, data } = useSWRImmutable(['contacts', session?.connectionId]); + + useEffect(() => { + if (!session?.connectionId) return; + const provider = dexieStorageProvider(); + // provider.list(`$inf$@"${session?.connectionId}"`).then((cachedThreadsResponses) => { + // const seen = new Set(); + // const contacts: Sender[] = cachedThreadsResponses.reduce((acc: Sender[], { state }) => { + // if (state.data) { + // for (const thread of state.data[0].threads) { + // const email = thread.sender.email; + // if (!seen.has(email)) { + // seen.add(email); + // acc.push(thread.sender); + // } + // } + // } + // return acc; + // }, []); + // mutate(contacts); + // }); + }, [session?.connectionId]); + + if (!data) { + return []; + } + + return data; +}; diff --git a/apps/mail/hooks/use-stats.ts b/apps/mail/hooks/use-stats.ts index 3a89e49d94..66c05d1f66 100644 --- a/apps/mail/hooks/use-stats.ts +++ b/apps/mail/hooks/use-stats.ts @@ -1,6 +1,7 @@ 'use client'; import { useSession } from '@/lib/auth-client'; import { mailCount } from '@/actions/mail'; +import axios from 'axios'; import useSWR from 'swr'; export const useStats = () => { @@ -13,7 +14,7 @@ export const useStats = () => { error, } = useSWR<{ label: string; count: number }[]>( session?.connectionId ? `/mail-count/${session?.connectionId}` : null, - mailCount, + () => axios.get('/api/driver/count').then((res) => res.data), ); return { diff --git a/apps/mail/hooks/use-threads.ts b/apps/mail/hooks/use-threads.ts index 0858f11b32..f73edbaff4 100644 --- a/apps/mail/hooks/use-threads.ts +++ b/apps/mail/hooks/use-threads.ts @@ -1,14 +1,15 @@ 'use client'; import { useParams, useSearchParams } from 'next/navigation'; +import { IGetThreadResponse } from '@/app/api/driver/types'; import type { InitialThread, ParsedMessage } from '@/types'; import { useSearchValue } from '@/hooks/use-search-value'; import { useSession } from '@/lib/auth-client'; import { defaultPageSize } from '@/lib/utils'; import useSWRInfinite from 'swr/infinite'; import useSWR, { preload } from 'swr'; +import { useQueryState } from 'nuqs'; import { useMemo } from 'react'; import axios from 'axios'; -import { useQueryState } from 'nuqs'; export const preloadThread = async (userId: string, threadId: string, connectionId: string) => { console.log(`🔄 Prefetching email ${threadId}...`); @@ -51,7 +52,7 @@ const fetchEmails = async ([ const fetchThread = async (args: any[]) => { const [_, id] = args; try { - const response = await axios.get(`/api/driver/${id}`); + const response = await axios.get(`/api/driver/${id}`); return response.data; } catch (error) { console.error('Error fetching email:', error); @@ -79,6 +80,11 @@ export const useThreads = () => { const { folder } = useParams<{ folder: string }>(); const [searchValue] = useSearchValue(); const { data: session } = useSession(); + const searchParams = new URLSearchParams({ + q: searchValue.value, + folder, + max: defaultPageSize.toString(), + }); const { data, error, size, setSize, isLoading, isValidating, mutate } = useSWRInfinite( (_, previousPageData) => { @@ -90,7 +96,7 @@ export const useThreads = () => { defaultPageSize, ]); }, - fetchEmails, + () => axios.get(`/api/driver?${searchParams.toString()}`).then((res) => res.data), { revalidateOnFocus: false, revalidateOnReconnect: false, @@ -125,14 +131,14 @@ export const useThreads = () => { export const useThread = (threadId: string | null) => { const { data: session } = useSession(); const [_threadId] = useQueryState('threadId'); - const id = threadId ? threadId : _threadId + const id = threadId ? threadId : _threadId; - const { data, isLoading, error, mutate } = useSWR( + const { data, isLoading, error, mutate } = useSWR( session?.user.id && id ? [session.user.id, id, session.connectionId] : null, - fetchThread, + () => axios.get(`/api/driver/${id}`).then((res) => res.data), ); - const hasUnread = useMemo(() => data?.some((e) => e.unread), [data]); + const hasUnread = useMemo(() => data?.messages.some((e) => e.unread), [data]); return { data, isLoading, error, hasUnread, mutate }; }; diff --git a/apps/mail/lib/auth.ts b/apps/mail/lib/auth.ts index 85c9c523ec..93d5353091 100644 --- a/apps/mail/lib/auth.ts +++ b/apps/mail/lib/auth.ts @@ -72,7 +72,7 @@ const options = { .select({ activeConnectionId: _user.defaultConnectionId, hasEarlyAccess: earlyAccess.isEarlyAccess, - hasUsedTicket: earlyAccess.hasUsedTicket + hasUsedTicket: earlyAccess.hasUsedTicket, }) .from(_user) .leftJoin(earlyAccess, eq(_user.email, earlyAccess.email)) @@ -80,7 +80,11 @@ const options = { .limit(1); // Check early access and proceed - if (!foundUser?.hasEarlyAccess && process.env.NODE_ENV === 'production') { + if ( + !foundUser?.hasEarlyAccess && + process.env.NODE_ENV === 'production' && + process.env.EARLY_ACCESS_ENABLED + ) { await db .insert(earlyAccess) .values({ @@ -92,8 +96,7 @@ const options = { .catch((err) => console.log('Tried to add user to earlyAccess after error, failed', foundUser), ); - redirect('/login?error=early_access_required'); - + redirect('/login?error=early_access_required'); } let activeConnection = null; @@ -114,9 +117,12 @@ const options = { picture: connectionDetails.picture, }; } else { - await db.update(_user).set({ - defaultConnectionId: null, - }).where(eq(_user.id, user.id)); + await db + .update(_user) + .set({ + defaultConnectionId: null, + }) + .where(eq(_user.id, user.id)); } } @@ -174,7 +180,7 @@ const options = { activeConnection, user, session, - hasUsedTicket: foundUser?.hasUsedTicket ?? false + hasUsedTicket: foundUser?.hasUsedTicket ?? false, }; }), ], diff --git a/apps/mail/lib/idb.ts b/apps/mail/lib/idb.ts index 4336c39c14..5106307901 100644 --- a/apps/mail/lib/idb.ts +++ b/apps/mail/lib/idb.ts @@ -1,5 +1,6 @@ import type { Cache, State } from "swr"; import Dexie from "dexie"; +import { InitialThread } from "@/types"; interface CacheEntry { key: string; @@ -21,7 +22,7 @@ class SWRDatabase extends Dexie { const db = new SWRDatabase(); const ONE_DAY = 1000 * 60 * 60 * 24; -export function dexieStorageProvider(): Cache & { clear: () => void } { +export function dexieStorageProvider(): Cache & { clear: () => void; list: (prefix: string) => Promise<{ key: string, state: { data?: [{ threads: InitialThread[] }] } }[]> } { const memoryCache = new Map>(); db.cache @@ -37,6 +38,10 @@ export function dexieStorageProvider(): Cache & { clear: () => void } { return memoryCache.keys(); }, + list(prefix: string) { + return db.cache.where("key").startsWith(prefix).toArray(); + }, + get(key: string) { return memoryCache.get(key); }, diff --git a/apps/mail/types/index.ts b/apps/mail/types/index.ts index 281461a3e7..84bb93804e 100644 --- a/apps/mail/types/index.ts +++ b/apps/mail/types/index.ts @@ -70,16 +70,6 @@ export interface IConnection { export interface InitialThread { id: string; - threadId?: string; - title: string; - tags: string[]; - sender: Sender; - receivedOn: string; - unread: boolean; - subject: string; - totalReplies: number; - references?: string; - inReplyTo?: string; } export interface Attachment { @@ -95,19 +85,20 @@ export interface MailListProps { isCompact?: boolean; } -export type MailSelectMode = "mass" | "range" | "single" | "selectAllBelow"; +export type MailSelectMode = 'mass' | 'range' | 'single' | 'selectAllBelow'; export type ThreadProps = { - message: InitialThread; + message: { id: string }; selectMode: MailSelectMode; // TODO: enforce types instead of sprinkling "any" - onClick?: (message: InitialThread) => () => void; + onClick?: (message: ParsedMessage) => () => void; isCompact?: boolean; folder?: string; isKeyboardFocused?: boolean; isInQuickActionMode?: boolean; selectedQuickActionIndex?: number; resetNavigation?: () => void; + demoMessage?: ParsedMessage; }; export type ConditionalThreadProps = ThreadProps & @@ -116,15 +107,13 @@ export type ConditionalThreadProps = ThreadProps & | { demo?: false; sessionData: { userId: string; connectionId: string | null } } ); - - export interface IOutgoingMessage { to: Sender[]; cc?: Sender[]; bcc?: Sender[]; - subject: string - message: string - attachments: any[] - headers: Record - threadId?: string -} \ No newline at end of file + subject: string; + message: string; + attachments: any[]; + headers: Record; + threadId?: string; +} diff --git a/packages/db/migrations/0019_mean_war_machine.sql b/packages/db/migrations/0019_mean_war_machine.sql index f99bdba6e0..23433c5073 100644 --- a/packages/db/migrations/0019_mean_war_machine.sql +++ b/packages/db/migrations/0019_mean_war_machine.sql @@ -1,3 +1,3 @@ ALTER TABLE "mail0_user_settings" ALTER COLUMN "settings" SET DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":true,"customPrompt":"","signature":{"enabled":false,"content":"","includeByDefault":true}}'::jsonb;--> statement-breakpoint --- ALTER TABLE "mail0_user" ADD COLUMN "custom_prompt" text;--> statement-breakpoint +ALTER TABLE "mail0_user" ADD COLUMN "custom_prompt" text;--> statement-breakpoint ALTER TABLE "mail0_connection" ADD CONSTRAINT "mail0_connection_user_id_email_unique" UNIQUE("user_id","email"); \ No newline at end of file