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..e31f470500 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 { 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 null; + + 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..b4ac9e0720 100644 --- a/apps/mail/app/api/driver/route.ts +++ b/apps/mail/app/api/driver/route.ts @@ -1,15 +1,24 @@ +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 +26,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/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index 4562368914..7e6fe4550c 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'; @@ -36,6 +36,7 @@ import { useQueryState } from 'nuqs'; import { Categories } from './mail'; import items from './demo.json'; import { toast } from 'sonner'; +import { Skeleton } from '../ui/skeleton'; const HOVER_DELAY = 1000; const ThreadWrapper = ({ @@ -80,6 +81,7 @@ const Thread = memo( onClick, sessionData, isKeyboardFocused, + demoMessage, }: ConditionalThreadProps) => { const [mail] = useMail(); const [searchValue] = useSearchValue(); @@ -90,17 +92,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 +115,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 +128,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 +161,30 @@ const Thread = memo( }; }, []); - const demoContent =
+ if (isLoading || !latestMessage || !getThreadData) return
+
+
+ +
+ +
+ + +
+ + +
+
+ + const demoContent =
- {message?.sender?.name[0]?.toUpperCase()} + {latestMessage.sender?.name[0]?.toUpperCase()}
@@ -190,39 +212,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,7 +253,7 @@ const Thread = memo( 'mt-1 line-clamp-1 text-xs opacity-70 transition-opacity', )} > - {highlightText(message.subject, searchValue.highlight)} + {highlightText(latestMessage.subject, searchValue.highlight)}

@@ -240,15 +262,15 @@ const Thread = memo(
const content = ( -
+
- {message?.sender?.name[0]?.toUpperCase()} + {latestMessage.sender.name[0]?.toUpperCase()}
@@ -276,46 +298,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)}

@@ -329,7 +351,7 @@ const Thread = memo( return demo ? demoContent : ( () => onSelectMail && onSelectMail(message)} + demoMessage={item as any} /> ) : null; })} @@ -471,7 +494,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 +587,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => { }, [isKeyPressed]); const handleMailClick = useCallback( - (message: InitialThread) => () => { + (message: ParsedMessage) => () => { handleMouseEnter(message.id); const messageThreadId = message.threadId ?? message.id; diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 5d20e7c34e..2fc576c428 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -451,7 +451,7 @@ function BulkSelectActions() { await new Promise((resolve) => setTimeout(resolve, 499)); const emailData = await getMail({ id: bulkSelected }); if (emailData) { - const [firstEmail] = emailData; + const firstEmail = emailData.latest; if (firstEmail) return handleUnsubscribe({ emailData: firstEmail }).catch((e) => { toast.error(e.message ?? 'Unknown error while unsubscribing'); diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index 3f27f95fbb..cb64149e48 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -250,7 +250,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { } if (!emailData) return; try { - const originalEmail = emailData[emailData.length - 1]; + const originalEmail = emailData.latest const userEmail = session?.activeConnection?.email?.toLowerCase(); if (!userEmail) { @@ -527,12 +527,12 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { aiDispatch({ type: 'SET_LOADING', payload: true }); try { // Extract relevant information from the email thread for context - const latestEmail = emailData[emailData.length - 1]; + const latestEmail = emailData.latest; if (!latestEmail) return; const originalSender = latestEmail?.sender?.name || 'the recipient'; // Create a summary of the thread content for context - const threadContent = (await Promise.all(emailData.map(async (email) => { + const threadContent = (await Promise.all(emailData.messages.map(async (email) => { const body = await extractTextFromHTML(email.decodedBody || 'No content'); return ` @@ -616,9 +616,9 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { // Helper function to initialize recipients based on mode const initializeRecipients = useCallback(() => { - if (!emailData || !emailData.length) return { to: [], cc: [] }; + if (!emailData || !emailData.messages.length) return { to: [], cc: [] }; - const latestEmail = emailData[emailData.length - 1]; + const latestEmail = emailData.latest; if (!latestEmail) return { to: [], cc: [] }; const userEmail = session?.activeConnection?.email?.toLowerCase(); @@ -690,7 +690,7 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { const renderHeaderContent = () => { if (!emailData) return null; - const latestEmail = emailData[emailData.length - 1]; + const latestEmail = emailData.latest; if (!latestEmail) return null; const icon = @@ -894,12 +894,12 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { // Update saveDraft function const saveDraft = useCallback(async () => { - if (!emailData || !emailData[0]) return; + if (!emailData || !emailData.latest) return; if (!getValues('messageContent')) return; try { composerDispatch({ type: 'SET_LOADING', payload: true }); - const originalEmail = emailData[0]; + const originalEmail = emailData.latest; const draftData = { to: mode === 'forward' ? getValues('to').join(', ') : originalEmail.sender.email, subject: originalEmail.subject?.startsWith(mode === 'forward' ? 'Fwd: ' : 'Re: ') @@ -927,10 +927,10 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { // Simplified composer visibility check if (!composerIsOpen) { - if (!emailData || emailData.length === 0) return null; + if (!emailData || emailData.messages.length === 0) return null; // Get the latest email in the thread - const latestEmail = emailData[emailData.length - 1]; + const latestEmail = emailData.latest; if (!latestEmail) return null; // Get all unique participants (excluding current user) @@ -1118,8 +1118,8 @@ export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) { email: session?.user.email, }} senderInfo={{ - name: emailData[0]?.sender?.name, - email: emailData[0]?.sender?.email, + name: emailData.latest?.sender?.name, + email: emailData.latest?.sender?.email, }} />
diff --git a/apps/mail/components/mail/thread-display.tsx b/apps/mail/components/mail/thread-display.tsx index bfa72faba1..79dc0bb2ae 100644 --- a/apps/mail/components/mail/thread-display.tsx +++ b/apps/mail/components/mail/thread-display.tsx @@ -149,7 +149,7 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { // Check if thread contains any images (excluding sender avatars) const hasImages = useMemo(() => { if (!emailData) return false; - return emailData.some(message => { + return emailData.messages.some(message => { const hasAttachments = message.attachments?.some(attachment => attachment.mimeType?.startsWith('image/') ); @@ -159,27 +159,27 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { }); }, [emailData]); - const hasMultipleParticipants = (emailData?.[0]?.to?.length ?? 0) + (emailData?.[0]?.cc?.length ?? 0) + 1 > 2; + const hasMultipleParticipants = (emailData?.latest?.to?.length ?? 0) + (emailData?.latest?.cc?.length ?? 0) + 1 > 2; /** * Mark email as read if it's unread, if there are no unread emails, mark the current thread as read */ useEffect(() => { if (!emailData || !id) return; - const unreadEmails = emailData.filter(e => e.unread); + const unreadEmails = emailData.messages.filter(e => e.unread); if (unreadEmails.length === 0) { console.log('Marking email as read:', id); markAsRead({ ids: [id] }).catch((error) => { console.error('Failed to mark email as read:', error); toast.error(t('common.mail.failedToMarkAsRead')); - }).then(() => Promise.all([mutateThread(), mutateThreads(), mutateStats()])) + }).then(() => Promise.all([mutateThread(), mutateStats()])) } else { console.log('Marking email as read:', id, ...unreadEmails.map(e => e.id)); const ids = [id, ...unreadEmails.map(e => e.id)] markAsRead({ ids }).catch((error) => { console.error('Failed to mark email as read:', error); toast.error(t('common.mail.failedToMarkAsRead')); - }).then(() => Promise.all([mutateThread(), mutateThreads(), mutateStats()])) + }).then(() => Promise.all([mutateThread(), mutateStats()])) } }, [emailData, id]) @@ -241,7 +241,7 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { const handleFavourites = async () => { if (!emailData || !threadId) return; const done = Promise.all([mutateThreads()]); - if (emailData[0]?.tags?.includes('STARRED')) { + if (emailData.latest?.tags?.includes('STARRED')) { toast.promise( modifyLabels({ threadId: [threadId], removeLabels: ['STARRED'] }).then(() => done), { @@ -321,7 +321,7 @@ export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { label={t('common.actions.close')} onClick={handleClose} /> - +
{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/hooks/use-contacts.ts b/apps/mail/hooks/use-contacts.ts index a21d1648d6..50f5b23dc4 100644 --- a/apps/mail/hooks/use-contacts.ts +++ b/apps/mail/hooks/use-contacts.ts @@ -11,22 +11,22 @@ export const useContacts = () => { 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); - }); + // 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) { 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..5fe8bbe019 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); @@ -90,7 +91,7 @@ export const useThreads = () => { defaultPageSize, ]); }, - fetchEmails, + () => axios.get(`/api/driver`).then((res) => res.data), { revalidateOnFocus: false, revalidateOnReconnect: false, @@ -125,14 +126,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/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; +}