From d73bf3b773b24808a1aa72a1418039f12990ad76 Mon Sep 17 00:00:00 2001 From: alma Date: Sat, 26 Apr 2025 09:14:44 +0200 Subject: [PATCH] panel 2 courier api restore --- app/api/courrier/[id]/mark-read/route.ts | 91 +--- app/api/courrier/[id]/route.ts | 140 +----- app/api/courrier/credentials/route.ts | 23 +- app/api/courrier/login/route.ts | 128 ++--- app/api/courrier/route.ts | 403 ++------------- app/api/courrier/send/route.ts | 91 +--- app/api/mail/route.ts | 104 +--- app/api/mail/send/route.ts | 115 +---- components/ComposeEmail.tsx | 25 +- components/email.tsx | 217 ++++---- lib/services/email-service.ts | 600 +++++++++++++++++++++++ 11 files changed, 891 insertions(+), 1046 deletions(-) create mode 100644 lib/services/email-service.ts diff --git a/app/api/courrier/[id]/mark-read/route.ts b/app/api/courrier/[id]/mark-read/route.ts index 0b6258bd..66022b10 100644 --- a/app/api/courrier/[id]/mark-read/route.ts +++ b/app/api/courrier/[id]/mark-read/route.ts @@ -1,11 +1,9 @@ import { NextResponse } from 'next/server'; -import { ImapFlow } from 'imapflow'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { prisma } from '@/lib/prisma'; +import { markEmailReadStatus } from '@/lib/services/email-service'; -// Get the email list cache from main API route -// This is a hack - ideally we'd use a shared module or Redis for caching +// Global cache reference (will be moved to a proper cache solution in the future) declare global { var emailListCache: { [key: string]: { data: any, timestamp: number } }; } @@ -47,79 +45,42 @@ export async function POST( return NextResponse.json({ error: 'Email ID is required' }, { status: 400 }); } - // Get credentials from database - const credentials = await prisma.mailCredentials.findUnique({ - where: { userId: session.user.id }, - }); - - if (!credentials) { - return NextResponse.json({ error: 'No mail credentials found' }, { status: 401 }); - } - - // Connect to IMAP server - const client = new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - emitLogs: false, - tls: { - rejectUnauthorized: false - } - }); - try { - await client.connect(); + // Use the email service to mark the email as read + // First try with INBOX folder + let success = await markEmailReadStatus(session.user.id, emailId, true, 'INBOX'); - // Find which folder contains this email - const mailboxes = await client.list(); - let emailFolder = 'INBOX'; // Default to INBOX - let foundEmail = false; - - // Search through folders to find the email - for (const box of mailboxes) { - try { - await client.mailboxOpen(box.path); - - // Search for the email by UID - const message = await client.fetchOne(emailId, { flags: true }); - if (message) { - emailFolder = box.path; - foundEmail = true; - - // Mark as read if not already - if (!message.flags.has('\\Seen')) { - await client.messageFlagsAdd(emailId, ['\\Seen']); - } + // If not found in INBOX, try to find it in other common folders + if (!success) { + const commonFolders = ['Sent', 'Drafts', 'Trash', 'Spam', 'Junk']; + + for (const folder of commonFolders) { + success = await markEmailReadStatus(session.user.id, emailId, true, folder); + if (success) { + // If found in a different folder, invalidate that folder's cache + invalidateCache(session.user.id, folder); break; } - } catch (error) { - console.log(`Error searching in folder ${box.path}:`, error); - // Continue with next folder } + } else { + // Email found in INBOX, invalidate INBOX cache + invalidateCache(session.user.id, 'INBOX'); } - - if (!foundEmail) { + + if (!success) { return NextResponse.json( - { error: 'Email not found' }, + { error: 'Email not found in any folder' }, { status: 404 } ); } - - // Invalidate the cache for this folder - invalidateCache(session.user.id, emailFolder); return NextResponse.json({ success: true }); - } finally { - try { - await client.logout(); - } catch (e) { - console.error('Error during logout:', e); - } + } catch (error) { + console.error('Error marking email as read:', error); + return NextResponse.json( + { error: 'Failed to mark email as read' }, + { status: 500 } + ); } } catch (error) { console.error('Error marking email as read:', error); diff --git a/app/api/courrier/[id]/route.ts b/app/api/courrier/[id]/route.ts index 449e4391..9e06836d 100644 --- a/app/api/courrier/[id]/route.ts +++ b/app/api/courrier/[id]/route.ts @@ -7,14 +7,9 @@ */ import { NextResponse } from 'next/server'; -import { ImapFlow } from 'imapflow'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { prisma } from '@/lib/prisma'; -import { simpleParser } from 'mailparser'; - -// Simple in-memory cache for email content -const emailContentCache = new Map(); +import { getEmailContent, markEmailReadStatus } from '@/lib/services/email-service'; export async function GET( request: Request, @@ -37,67 +32,18 @@ export async function GET( ); } - // Get mail credentials - const credentials = await prisma.mailCredentials.findUnique({ - where: { - userId: session.user.id, - }, - }); - - if (!credentials) { - return NextResponse.json( - { error: "No mail credentials found" }, - { status: 404 } - ); - } - const { searchParams } = new URL(request.url); const folder = searchParams.get("folder") || "INBOX"; - // Create IMAP client - let imapClient: any = null; try { - imapClient = new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - }); - - await imapClient.connect(); - console.log(`Connected to IMAP server to fetch full email ${id}`); - - // Select mailbox - const mailboxData = await imapClient.mailboxOpen(folder); - console.log(`Opened mailbox ${folder} to fetch email ${id}`); - - // Fetch the complete email with its source - const message = await imapClient.fetchOne(Number(id), { - source: true, - envelope: true - }); - - if (!message) { - return NextResponse.json( - { error: "Email not found" }, - { status: 404 } - ); - } - - const { source, envelope } = message; + // Use the email service to fetch the email content + const email = await getEmailContent(session.user.id, id, folder); - // Parse the full email content - const parsedEmail = await simpleParser(source.toString()); - - // Return only the content + // Return only what's needed for displaying the email return NextResponse.json({ id, - subject: envelope.subject, - content: parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || '', + subject: email.subject, + content: email.content, contentFetched: true }); } catch (error: any) { @@ -106,16 +52,6 @@ export async function GET( { error: "Failed to fetch email content", message: error.message }, { status: 500 } ); - } finally { - // Close the mailbox and connection - if (imapClient) { - try { - await imapClient.mailboxClose(); - await imapClient.logout(); - } catch (e) { - console.error("Error closing IMAP connection:", e); - } - } } } catch (error: any) { console.error("Error in GET:", error); @@ -157,67 +93,25 @@ export async function POST( ); } - // Get mail credentials - const credentials = await prisma.mailCredentials.findUnique({ - where: { - userId: session.user.id, - }, - }); - - if (!credentials) { - return NextResponse.json( - { error: "No mail credentials found" }, - { status: 404 } - ); - } - const { searchParams } = new URL(request.url); const folder = searchParams.get("folder") || "INBOX"; - // Create IMAP client - let imapClient: any = null; - try { - imapClient = new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - }); + // Use the email service to mark the email + const success = await markEmailReadStatus( + session.user.id, + id, + action === 'mark-read', + folder + ); - await imapClient.connect(); - - // Select mailbox - await imapClient.mailboxOpen(folder); - - // Set flag based on action - if (action === 'mark-read') { - await imapClient.messageFlagsAdd(Number(id), ['\\Seen']); - } else { - await imapClient.messageFlagsRemove(Number(id), ['\\Seen']); - } - - return NextResponse.json({ success: true }); - } catch (error: any) { - console.error(`Error ${action === 'mark-read' ? 'marking email as read' : 'marking email as unread'}:`, error); + if (!success) { return NextResponse.json( - { error: `Failed to ${action === 'mark-read' ? 'mark email as read' : 'mark email as unread'}`, message: error.message }, + { error: `Failed to ${action === 'mark-read' ? 'mark email as read' : 'mark email as unread'}` }, { status: 500 } ); - } finally { - // Close the mailbox and connection - if (imapClient) { - try { - await imapClient.mailboxClose(); - await imapClient.logout(); - } catch (e) { - console.error("Error closing IMAP connection:", e); - } - } } + + return NextResponse.json({ success: true }); } catch (error: any) { console.error("Error in POST:", error); return NextResponse.json( diff --git a/app/api/courrier/credentials/route.ts b/app/api/courrier/credentials/route.ts index 5b1e0220..789f9b08 100644 --- a/app/api/courrier/credentials/route.ts +++ b/app/api/courrier/credentials/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { prisma } from '@/lib/prisma'; +import { getUserEmailCredentials } from '@/lib/services/email-service'; export async function GET() { try { @@ -24,17 +24,8 @@ export async function GET() { }, { status: 401 }); } - // Fetch mail credentials for this user - const mailCredentials = await prisma.mailCredentials.findUnique({ - where: { - userId: userId - }, - select: { - email: true, - host: true, - port: true - } - }); + // Fetch mail credentials for this user using our service + const mailCredentials = await getUserEmailCredentials(userId); // If no credentials found if (!mailCredentials) { @@ -44,10 +35,14 @@ export async function GET() { }, { status: 404 }); } - // Return the credentials + // Return the credentials (excluding password) console.log(`Successfully retrieved mail credentials for user ID: ${userId}`); return NextResponse.json({ - credentials: mailCredentials + credentials: { + email: mailCredentials.email, + host: mailCredentials.host, + port: mailCredentials.port + } }); } catch (error) { diff --git a/app/api/courrier/login/route.ts b/app/api/courrier/login/route.ts index a4b9e47f..3a7df791 100644 --- a/app/api/courrier/login/route.ts +++ b/app/api/courrier/login/route.ts @@ -1,107 +1,65 @@ import { NextResponse } from 'next/server'; -import { ImapFlow } from 'imapflow'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { prisma } from '@/lib/prisma'; +import { + saveUserEmailCredentials, + getUserEmailCredentials, + testEmailConnection +} from '@/lib/services/email-service'; export async function POST(request: Request) { try { - console.log('Processing login POST request'); + // Authenticate user const session = await getServerSession(authOptions); if (!session?.user?.id) { - console.log('No authenticated session found'); return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } + // Get credentials from request const { email, password, host, port } = await request.json(); - console.log('Login attempt for:', email, 'to server:', host); + // Validate required fields if (!email || !password || !host || !port) { - console.log('Missing required login fields'); return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - // Test IMAP connection - console.log('Testing IMAP connection to:', host, port); - const client = new ImapFlow({ - host: host, - port: parseInt(port), - secure: true, - auth: { - user: email, - pass: password, - }, - logger: false, - emitLogs: false, - tls: { - rejectUnauthorized: false - } + // Test connection before saving + const connectionSuccess = await testEmailConnection({ + email, + password, + host, + port: parseInt(port) }); - try { - await client.connect(); - console.log('IMAP connection successful'); - await client.mailboxOpen('INBOX'); - console.log('INBOX opened successfully'); - - // Store or update credentials in database - await prisma.mailCredentials.upsert({ - where: { - userId: session.user.id - }, - update: { - email, - password, - host, - port: parseInt(port) - }, - create: { - userId: session.user.id, - email, - password, - host, - port: parseInt(port) - } - }); - console.log('Credentials stored in database'); - - return NextResponse.json({ success: true }); - } catch (error) { - console.error('IMAP connection error:', error); - if (error instanceof Error) { - if (error.message.includes('Invalid login')) { - return NextResponse.json( - { error: 'Invalid login or password' }, - { status: 401 } - ); - } - return NextResponse.json( - { error: `IMAP connection error: ${error.message}` }, - { status: 500 } - ); - } + if (!connectionSuccess) { return NextResponse.json( - { error: 'Failed to connect to email server' }, - { status: 500 } + { error: 'Failed to connect to email server. Please check your credentials.' }, + { status: 401 } ); - } finally { - try { - await client.logout(); - console.log('IMAP client logged out'); - } catch (e) { - console.error('Error during logout:', e); - } } + + // Save credentials in the database + await saveUserEmailCredentials(session.user.id, { + email, + password, + host, + port: parseInt(port) + }); + + return NextResponse.json({ success: true }); } catch (error) { console.error('Error in login handler:', error); return NextResponse.json( - { error: 'An unexpected error occurred' }, + { + error: 'An unexpected error occurred', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 } ); } @@ -109,37 +67,31 @@ export async function POST(request: Request) { export async function GET() { try { - console.log('Fetching mail credentials'); + // Authenticate user const session = await getServerSession(authOptions); if (!session?.user?.id) { - console.log('No authenticated session found'); return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } - const credentials = await prisma.mailCredentials.findUnique({ - where: { - userId: session.user.id - }, - select: { - email: true, - host: true, - port: true - } - }); + // Get user credentials from database + const credentials = await getUserEmailCredentials(session.user.id); if (!credentials) { - console.log('No mail credentials found for user'); return NextResponse.json( { error: 'No stored credentials found' }, { status: 404 } ); } - console.log('Credentials found for:', credentials.email); - return NextResponse.json(credentials); + // Return credentials without the password + return NextResponse.json({ + email: credentials.email, + host: credentials.host, + port: credentials.port + }); } catch (error) { console.error('Error fetching credentials:', error); return NextResponse.json( diff --git a/app/api/courrier/route.ts b/app/api/courrier/route.ts index 1fe59640..c8050b60 100644 --- a/app/api/courrier/route.ts +++ b/app/api/courrier/route.ts @@ -1,88 +1,21 @@ import { NextResponse } from 'next/server'; -import { ImapFlow } from 'imapflow'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { prisma } from '@/lib/prisma'; -import { simpleParser } from 'mailparser'; +import { getEmails } from '@/lib/services/email-service'; -// Type definitions +// Simple in-memory cache (will be removed in a future update) interface EmailCacheEntry { data: any; timestamp: number; } -interface CredentialsCacheEntry { - client: any; // Use any for ImapFlow to avoid type issues - timestamp: number; -} - -// Email cache structure +// Cache for 1 minute only +const CACHE_TTL = 60 * 1000; const emailListCache: Record = {}; -const credentialsCache: Record = {}; - -// Cache TTL in milliseconds -const CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -// Helper function to get credentials with caching -async function getCredentialsWithCache(userId: string) { - // Check if we have fresh cached credentials - const cachedCreds = credentialsCache[userId]; - const now = Date.now(); - - if (cachedCreds && now - cachedCreds.timestamp < CACHE_TTL) { - return cachedCreds.client; - } - - // Otherwise fetch from database - const credentials = await prisma.mailCredentials.findUnique({ - where: { userId } - }); - - // Cache the result - if (credentials) { - credentialsCache[userId] = { - client: credentialsCache[userId]?.client || new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - }), - timestamp: now - }; - } - - return credentialsCache[userId]?.client || null; -} - -// Retry logic for IMAP operations -async function retryOperation(operation: () => Promise, maxAttempts = 3, delay = 1000): Promise { - let lastError: Error; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error as Error; - console.warn(`Operation failed (attempt ${attempt}/${maxAttempts}):`, error); - - if (attempt < maxAttempts) { - // Exponential backoff - const backoffDelay = delay * Math.pow(2, attempt - 1); - console.log(`Retrying in ${backoffDelay}ms...`); - await new Promise(resolve => setTimeout(resolve, backoffDelay)); - } - } - } - - throw lastError!; -} export async function GET(request: Request) { try { + // Authenticate user const session = await getServerSession(authOptions); if (!session || !session.user?.id) { return NextResponse.json( @@ -91,310 +24,45 @@ export async function GET(request: Request) { ); } - // Get mail credentials - const credentials = await prisma.mailCredentials.findUnique({ - where: { - userId: session.user.id, - }, - }); - - if (!credentials) { - return NextResponse.json( - { error: "No mail credentials found" }, - { status: 404 } - ); - } - + // Extract query parameters const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get("page") || "1"); const perPage = parseInt(searchParams.get("perPage") || "20"); const folder = searchParams.get("folder") || "INBOX"; const searchQuery = searchParams.get("search") || ""; - - // Check for entry in emailCache + + // Check cache - temporary until we implement a proper server-side cache const cacheKey = `${session.user.id}:${folder}:${page}:${perPage}:${searchQuery}`; + const now = Date.now(); const cachedEmails = emailListCache[cacheKey]; - if (cachedEmails) { + if (cachedEmails && now - cachedEmails.timestamp < CACHE_TTL) { console.log(`Using cached emails for ${cacheKey}`); return NextResponse.json(cachedEmails.data); } - console.log(`Cache miss for ${cacheKey}, fetching from IMAP`); + console.log(`Cache miss for ${cacheKey}, fetching emails`); - // Fetch from IMAP - const cacheCredKey = `credentials:${session.user.id}`; - let imapClient: any = credentialsCache[cacheCredKey]?.client || null; + // Use the email service to fetch emails + const emailsResult = await getEmails( + session.user.id, + folder, + page, + perPage, + searchQuery + ); - if (!imapClient) { - // Create IMAP client - const connectWithRetry = async (retries = 3, delay = 1000): Promise => { - try { - console.log(`Attempting to connect to IMAP server (${credentials.host}:${credentials.port})...`); - const client = new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - }); - - await client.connect(); - console.log("Successfully connected to IMAP server"); - return client; - } catch (error) { - if (retries > 0) { - console.log(`Connection failed, retrying... (${retries} attempts left)`); - await new Promise((resolve) => setTimeout(resolve, delay)); - return connectWithRetry(retries - 1, delay * 1.5); - } - throw error; - } - }; - - try { - imapClient = await connectWithRetry(); - // Cache for future use - credentialsCache[cacheCredKey] = { - client: imapClient, - timestamp: Date.now() - }; - } catch (error: any) { - console.error("Failed to connect to IMAP server after retries:", error); - return NextResponse.json( - { error: "Failed to connect to IMAP server", message: error.message }, - { status: 500 } - ); - } - } else { - console.log("Using cached IMAP client connection"); - } - - // Function to get mailboxes - const getMailboxes = async () => { - try { - console.log("Getting list of mailboxes..."); - const mailboxes = []; - const list = await imapClient.list(); - console.log(`Found ${list.length} mailboxes from IMAP server`); - - for (const mailbox of list) { - mailboxes.push(mailbox.path); - } - - console.log("Available mailboxes:", mailboxes); - return mailboxes; - } catch (error) { - console.error("Error listing mailboxes:", error); - return []; - } + // Cache the results + emailListCache[cacheKey] = { + data: emailsResult, + timestamp: now }; - // Setup paging - const startIdx = (page - 1) * perPage + 1; - const endIdx = page * perPage; - - let emails: any[] = []; - let mailboxData = null; - - try { - // Select and lock mailbox - mailboxData = await imapClient.mailboxOpen(folder); - console.log(`Opened mailbox ${folder}, ${mailboxData.exists} messages total`); - - // Calculate range based on total messages - const totalMessages = mailboxData.exists; - const from = Math.max(totalMessages - endIdx + 1, 1); - const to = Math.max(totalMessages - startIdx + 1, 1); - - // Skip if no messages or invalid range - if (totalMessages === 0 || from > to) { - console.log("No messages in range, returning empty array"); - const result = { - emails: [], - totalEmails: 0, - page, - perPage, - totalPages: 0, - folder, - mailboxes: await getMailboxes(), - }; - emailListCache[cacheKey] = { - data: result, - timestamp: Date.now() - }; - return NextResponse.json(result); - } - - console.log(`Fetching messages ${from}:${to} (page ${page}, ${perPage} per page)`); - - // Search if needed - let messageIds: any[] = []; - if (searchQuery) { - console.log(`Searching for: "${searchQuery}"`); - messageIds = await imapClient.search({ - body: searchQuery - }); - - // Filter to our page range - messageIds = messageIds.filter(id => id >= from && id <= to); - console.log(`Found ${messageIds.length} messages matching search`); - } else { - messageIds = Array.from( - { length: to - from + 1 }, - (_, i) => from + i - ); - } - - // Fetch messages with their content - for (const id of messageIds) { - try { - const message = await imapClient.fetchOne(id, { - envelope: true, - flags: true, - bodyStructure: true, - internalDate: true, - size: true, - // Only fetch a preview of the body initially for faster loading - bodyParts: [ - { - query: { - type: "text", - }, - limit: 5000, // Limit to first 5KB of text - } - ] - }); - - if (!message) continue; - - const { envelope, flags, bodyStructure, internalDate, size, bodyParts } = message; - - // Extract content from the body parts for a preview - let preview = ''; - if (bodyParts && bodyParts.length > 0) { - const textPart = bodyParts.find((part: any) => part.type === 'text/plain'); - const htmlPart = bodyParts.find((part: any) => part.type === 'text/html'); - // Prefer text for preview as it's smaller and faster to process - const content = textPart?.content || htmlPart?.content || ''; - - if (typeof content === 'string') { - preview = content.substring(0, 150) + '...'; - } else if (Buffer.isBuffer(content)) { - preview = content.toString('utf-8', 0, 150) + '...'; - } - } - - // Convert attachments to our format - const attachments: Array<{ - contentId?: string; - filename: string; - contentType: string; - size: number; - path: string; - }> = []; - - const processAttachments = (node: any, path: Array = []) => { - if (!node) return; - - if (node.type === 'attachment') { - attachments.push({ - contentId: node.contentId, - filename: node.filename || 'attachment', - contentType: node.contentType, - size: node.size, - path: [...path, node.part].join('.') - }); - } - - if (node.childNodes) { - node.childNodes.forEach((child: any, index: number) => { - processAttachments(child, [...path, node.part || index + 1]); - }); - } - }; - - if (bodyStructure) { - processAttachments(bodyStructure); - } - - // Convert flags from Set to boolean checks - const flagsArray = Array.from(flags as Set); - - emails.push({ - id, - messageId: envelope.messageId, - subject: envelope.subject || "(No Subject)", - from: envelope.from.map((f: any) => ({ - name: f.name || f.address, - address: f.address, - })), - to: envelope.to.map((t: any) => ({ - name: t.name || t.address, - address: t.address, - })), - cc: (envelope.cc || []).map((c: any) => ({ - name: c.name || c.address, - address: c.address, - })), - bcc: (envelope.bcc || []).map((b: any) => ({ - name: b.name || b.address, - address: b.address, - })), - date: internalDate || new Date(), - flags: { - seen: flagsArray.includes("\\Seen"), - flagged: flagsArray.includes("\\Flagged"), - answered: flagsArray.includes("\\Answered"), - deleted: flagsArray.includes("\\Deleted"), - draft: flagsArray.includes("\\Draft"), - }, - hasAttachments: attachments.length > 0, - attachments, - size, - // Just include the preview instead of the full content initially - preview, - // Store the fetched state to know we only have preview - contentFetched: false - }); - } catch (messageError) { - console.error(`Error fetching message ${id}:`, messageError); - } - } - - // Sort by date, newest first - emails.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - - const result = { - emails, - totalEmails: totalMessages, - page, - perPage, - totalPages: Math.ceil(totalMessages / perPage), - folder, - mailboxes: await getMailboxes(), - }; - - // Cache the result - emailListCache[cacheKey] = { - data: result, - timestamp: Date.now() - }; - - return NextResponse.json(result); - } finally { - // If we opened a mailbox, close it - if (mailboxData) { - await imapClient.mailboxClose(); - } - } + return NextResponse.json(emailsResult); } catch (error: any) { - console.error("Error in GET:", error); + console.error("Error fetching emails:", error); return NextResponse.json( - { error: "Internal server error", message: error.message }, + { error: "Failed to fetch emails", message: error.message }, { status: 500 } ); } @@ -407,30 +75,25 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const { emailId, folderName, action } = await request.json(); + const { emailId, folderName } = await request.json(); if (!emailId) { return NextResponse.json({ error: 'Missing emailId parameter' }, { status: 400 }); } - // Invalidate cache entries for this folder + // Invalidate cache entries for this folder or all folders if none specified const userId = session.user.id; - - // If folder is specified, only invalidate that folder's cache - if (folderName) { - Object.keys(emailListCache).forEach(key => { + Object.keys(emailListCache).forEach(key => { + if (folderName) { if (key.includes(`${userId}:${folderName}`)) { delete emailListCache[key]; } - }); - } else { - // Otherwise invalidate all cache entries for this user - Object.keys(emailListCache).forEach(key => { + } else { if (key.startsWith(`${userId}:`)) { delete emailListCache[key]; } - }); - } + } + }); return NextResponse.json({ success: true }); } catch (error) { diff --git a/app/api/courrier/send/route.ts b/app/api/courrier/send/route.ts index 85ee78e1..a88f44d0 100644 --- a/app/api/courrier/send/route.ts +++ b/app/api/courrier/send/route.ts @@ -1,100 +1,53 @@ import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { prisma } from '@/lib/prisma'; -import nodemailer from 'nodemailer'; +import { sendEmail } from '@/lib/services/email-service'; export async function POST(request: Request) { try { - console.log('Starting email send process...'); - + // Authenticate user const session = await getServerSession(authOptions); if (!session?.user?.id) { - console.log('No session found'); return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } - // Get credentials from database - console.log('Fetching credentials for user:', session.user.id); - const credentials = await prisma.mailCredentials.findUnique({ - where: { - userId: session.user.id - } - }); - - if (!credentials) { - console.log('No credentials found for user'); - return NextResponse.json( - { error: 'No mail credentials found. Please configure your email account.' }, - { status: 401 } - ); - } - - // Get the email data from the request + // Parse request body const { to, cc, bcc, subject, body, attachments } = await request.json(); - console.log('Email data received:', { to, cc, bcc, subject, attachments: attachments?.length || 0 }); - + + // Validate required fields if (!to) { - console.log('No recipient specified'); return NextResponse.json( { error: 'Recipient is required' }, { status: 400 } ); } - // Create SMTP transporter with Infomaniak SMTP settings - console.log('Creating SMTP transporter...'); - const transporter = nodemailer.createTransport({ - host: 'smtp.infomaniak.com', - port: 587, - secure: false, - auth: { - user: credentials.email, - pass: credentials.password, - }, - tls: { - rejectUnauthorized: false - }, - debug: true // Enable debug logging + // Use email service to send the email + const result = await sendEmail(session.user.id, { + to, + cc, + bcc, + subject, + body, + attachments }); - // Verify SMTP connection - console.log('Verifying SMTP connection...'); - try { - await transporter.verify(); - console.log('SMTP connection verified successfully'); - } catch (error) { - console.error('SMTP connection verification failed:', error); - throw error; + if (!result.success) { + return NextResponse.json( + { + error: 'Failed to send email', + details: result.error + }, + { status: 500 } + ); } - // Prepare email options - console.log('Preparing email options...'); - const mailOptions = { - from: credentials.email, - to: to, - cc: cc || undefined, - bcc: bcc || undefined, - subject: subject || '(No subject)', - html: body, - attachments: attachments?.map((file: any) => ({ - filename: file.name, - content: file.content, - contentType: file.type - })) || [] - }; - - // Send the email - console.log('Sending email...'); - const info = await transporter.sendMail(mailOptions); - console.log('Email sent successfully:', info.messageId); - return NextResponse.json({ success: true, - messageId: info.messageId + messageId: result.messageId }); } catch (error) { console.error('Error sending email:', error); diff --git a/app/api/mail/route.ts b/app/api/mail/route.ts index ed816f44..3f111623 100644 --- a/app/api/mail/route.ts +++ b/app/api/mail/route.ts @@ -1,88 +1,22 @@ import { NextResponse } from 'next/server'; -import { ImapFlow } from 'imapflow'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { prisma } from '@/lib/prisma'; -export async function GET() { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { error: 'Unauthorized' }, - { status: 401 } - ); - } - - // Get credentials from database - const credentials = await prisma.mailCredentials.findUnique({ - where: { - userId: session.user.id - } - }); - - if (!credentials) { - return NextResponse.json( - { error: 'No mail credentials found. Please configure your email account.' }, - { status: 401 } - ); - } - - // Connect to IMAP server - const client = new ImapFlow({ - host: credentials.host, - port: credentials.port, - secure: true, - auth: { - user: credentials.email, - pass: credentials.password, - }, - logger: false, - emitLogs: false, - tls: { - rejectUnauthorized: false - } - }); - - try { - await client.connect(); - const mailbox = await client.mailboxOpen('INBOX'); - - // Fetch only essential message data - const messages = await client.fetch('1:20', { - envelope: true, - flags: true - }); - - const result = []; - for await (const message of messages) { - result.push({ - id: message.uid.toString(), - from: message.envelope.from[0].address, - subject: message.envelope.subject || '(No subject)', - date: message.envelope.date.toISOString(), - read: message.flags.has('\\Seen'), - starred: message.flags.has('\\Flagged'), - folder: mailbox.path - }); - } - - return NextResponse.json({ - emails: result, - folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Spam'] - }); - } finally { - try { - await client.logout(); - } catch (e) { - console.error('Error during logout:', e); - } - } - } catch (error) { - console.error('Error in mail route:', error); - return NextResponse.json( - { error: 'An unexpected error occurred' }, - { status: 500 } - ); - } +/** + * This route is deprecated. It redirects to the new courrier API endpoint. + * @deprecated Use the /api/courrier endpoint instead + */ +export async function GET(request: Request) { + console.warn('Deprecated: /api/mail route is being used. Update your code to use /api/courrier instead.'); + + // Extract query parameters + const url = new URL(request.url); + + // Redirect to the new API endpoint + const redirectUrl = new URL('/api/courrier', url.origin); + + // Copy all search parameters + url.searchParams.forEach((value, key) => { + redirectUrl.searchParams.set(key, value); + }); + + return NextResponse.redirect(redirectUrl.toString()); } \ No newline at end of file diff --git a/app/api/mail/send/route.ts b/app/api/mail/send/route.ts index a068aa51..2a6ec36d 100644 --- a/app/api/mail/send/route.ts +++ b/app/api/mail/send/route.ts @@ -1,103 +1,32 @@ import { NextResponse } from 'next/server'; -import { createTransport } from 'nodemailer'; -import { cookies } from 'next/headers'; - -interface StoredCredentials { - email: string; - password: string; - host: string; - port: number; -} - -// Maximum attachment size in bytes (10MB) -const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; - -function getStoredCredentials(): StoredCredentials | null { - const cookieStore = cookies(); - const credentialsCookie = cookieStore.get('imap_credentials'); - - if (!credentialsCookie?.value) { - return null; - } - - try { - const credentials = JSON.parse(credentialsCookie.value); - if (!credentials.email || !credentials.password || !credentials.host || !credentials.port) { - return null; - } - return credentials; - } catch (error) { - return null; - } -} +/** + * This route is deprecated. It redirects to the new courrier API endpoint. + * @deprecated Use the /api/courrier/send endpoint instead + */ export async function POST(request: Request) { + console.warn('Deprecated: /api/mail/send route is being used. Update your code to use /api/courrier/send instead.'); + try { - const credentials = getStoredCredentials(); - if (!credentials) { - return NextResponse.json( - { error: 'No stored credentials found' }, - { status: 401 } - ); - } - - const { to, cc, bcc, subject, body, attachments } = await request.json(); - - // Check attachment sizes - if (attachments?.length) { - const oversizedAttachments = attachments.filter((attachment: any) => { - // Calculate size from base64 content - const size = Math.ceil((attachment.content.length * 3) / 4); - return size > MAX_ATTACHMENT_SIZE; - }); - - if (oversizedAttachments.length > 0) { - return NextResponse.json( - { - error: 'Attachment size limit exceeded', - details: { - maxSize: MAX_ATTACHMENT_SIZE, - oversizedFiles: oversizedAttachments.map((a: any) => a.name) - } - }, - { status: 400 } - ); - } - } - - // Create a transporter using SMTP with the same credentials - // Use port 465 for SMTP (Infomaniak's SMTP port) - const transporter = createTransport({ - host: credentials.host, - port: 465, // SMTP port for Infomaniak - secure: true, // Use TLS - auth: { - user: credentials.email, - pass: credentials.password, + // Clone the request body + const body = await request.json(); + + // Make a new request to the courrier API + const newRequest = new Request(new URL('/api/courrier/send', request.url).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' }, + body: JSON.stringify(body) }); - - // Prepare email options - const mailOptions = { - from: credentials.email, - to, - cc, - bcc, - subject, - text: body, - attachments: attachments?.map((attachment: any) => ({ - filename: attachment.name, - content: attachment.content, - encoding: attachment.encoding, - })), - }; - - // Send the email - await transporter.sendMail(mailOptions); - - return NextResponse.json({ success: true }); + + // Forward the request + const response = await fetch(newRequest); + const data = await response.json(); + + return NextResponse.json(data, { status: response.status }); } catch (error) { - console.error('Error sending email:', error); + console.error('Error forwarding to courrier/send:', error); return NextResponse.json( { error: 'Failed to send email' }, { status: 500 } diff --git a/components/ComposeEmail.tsx b/components/ComposeEmail.tsx index 442d57e9..88f16e2c 100644 --- a/components/ComposeEmail.tsx +++ b/components/ComposeEmail.tsx @@ -164,19 +164,22 @@ export default function ComposeEmail({ let formattedContent = ''; if (forwardFrom) { - formattedContent = ` -
-

---------- Forwarded message ---------

-

From: ${decoded.from || ''}

-

Date: ${formatDate(decoded.date)}

-

Subject: ${decoded.subject || ''}

-

To: ${decoded.to || ''}

-
- + // Create a clean header for the forwarded email + const headerHtml = ` +
+

---------- Forwarded message ---------

+

From: ${decoded.from || ''}

+

Date: ${formatDate(decoded.date)}

+

Subject: ${decoded.subject || ''}

+

To: ${decoded.to || ''}

`; + + // Use the original HTML as-is without DOMPurify or any modification + formattedContent = ` + ${headerHtml} + ${decoded.html || decoded.text || 'No content available'} + `; } else { formattedContent = `
diff --git a/components/email.tsx b/components/email.tsx index d12a9493..0f85c394 100644 --- a/components/email.tsx +++ b/components/email.tsx @@ -3,11 +3,8 @@ import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { RefreshCw, Mail } from "lucide-react"; -import { useSession } from "next-auth/react"; -import { formatDistance } from 'date-fns/formatDistance'; -import { fr } from 'date-fns/locale/fr'; -import { useRouter } from "next/navigation"; +import { RefreshCw, MessageSquare, Mail, MailOpen, Loader2 } from "lucide-react"; +import Link from 'next/link'; interface Email { id: string; @@ -28,161 +25,125 @@ interface EmailResponse { export function Email() { const [emails, setEmails] = useState([]); - const [mailUrl, setMailUrl] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [refreshing, setRefreshing] = useState(false); - const { data: session, status } = useSession(); - const router = useRouter(); + const [mailUrl, setMailUrl] = useState(null); + + useEffect(() => { + fetchEmails(); + }, []); const fetchEmails = async (isRefresh = false) => { - if (status !== 'authenticated') { - setError('Please sign in to view emails'); - setLoading(false); - setRefreshing(false); - return; - } + setLoading(true); + setError(null); - if (isRefresh) setRefreshing(true); - if (!isRefresh) setLoading(true); - try { - const response = await fetch('/api/mail'); - + const response = await fetch('/api/courrier?folder=INBOX&page=1&perPage=5'); if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch emails'); + throw new Error('Failed to fetch emails'); } const data = await response.json(); - + if (data.error) { - throw new Error(data.error); + setError(data.error); + setEmails([]); + } else { + // Transform data format if needed + const transformedEmails = data.emails.map((email: any) => ({ + id: email.id, + subject: email.subject, + from: email.from[0]?.address || '', + fromName: email.from[0]?.name || '', + date: email.date, + read: email.flags.seen, + starred: email.flags.flagged, + folder: email.folder + })).slice(0, 5); // Only show the first 5 emails + + setEmails(transformedEmails); + setMailUrl('/courrier'); } - - const validatedEmails = data.emails.map((email: any) => ({ - id: email.id || Date.now().toString(), - subject: email.subject || '(No subject)', - from: email.from || '', - fromName: email.fromName || email.from?.split('@')[0] || 'Unknown', - date: email.date || new Date().toISOString(), - read: !!email.read, - starred: !!email.starred, - folder: email.folder || 'INBOX' - })); - - setEmails(validatedEmails); - setMailUrl(data.mailUrl || 'https://espace.slm-lab.net/apps/courrier/'); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Error fetching emails'); + } catch (error) { + console.error('Error fetching emails:', error); + setError('Failed to load emails'); setEmails([]); } finally { setLoading(false); - setRefreshing(false); } }; - // Initial fetch - useEffect(() => { - if (status === 'authenticated') { - fetchEmails(); - } else if (status === 'unauthenticated') { - setError('Please sign in to view emails'); - setLoading(false); - } - }, [status]); - - // Auto-refresh every 5 minutes - useEffect(() => { - if (status !== 'authenticated') return; - - const interval = setInterval(() => { - fetchEmails(true); - }, 5 * 60 * 1000); - - return () => clearInterval(interval); - }, [status]); - const formatDate = (dateString: string) => { try { const date = new Date(dateString); - return formatDistance(date, new Date(), { - addSuffix: true, - locale: fr - }); - } catch (err) { - return dateString; + return new Intl.DateTimeFormat('fr-FR', { + month: 'short', + day: 'numeric' + }).format(date); + } catch (e) { + return ''; } }; - if (status === 'loading' || loading) { - return ( - - - -
- - Courrier -
-
-
- -
- -
-
-
- ); - } - return ( - - - -
- - Courrier -
+ + + + + Emails non lus - - + {error ? ( -

{error}

+
+ {error} +
+ ) : loading && emails.length === 0 ? ( +
+ +

Chargement des emails...

+
+ ) : emails.length === 0 ? ( +
+

Aucun email non lu

+
) : ( -
- {emails.length === 0 ? ( -

- {loading ? 'Loading emails...' : 'No unread emails'} -

- ) : ( - emails.map((email) => ( -
router.push('/mail')} - > -
- - {email.fromName || email.from} - -
- {!email.read && } - {formatDate(email.date)} -
-
-

{email.subject}

+
+ {emails.map((email) => ( +
+
+ {email.read ? + : + + }
- )) +
+
+

{email.fromName || email.from.split('@')[0]}

+

{formatDate(email.date)}

+
+

{email.subject}

+
+
+ ))} + + {mailUrl && ( +
+ + Voir tous les emails → + +
)}
)} diff --git a/lib/services/email-service.ts b/lib/services/email-service.ts new file mode 100644 index 00000000..55371c5e --- /dev/null +++ b/lib/services/email-service.ts @@ -0,0 +1,600 @@ +import { ImapFlow } from 'imapflow'; +import nodemailer from 'nodemailer'; +import { prisma } from '@/lib/prisma'; +import { simpleParser } from 'mailparser'; + +// Types for the email service +export interface EmailCredentials { + email: string; + password: string; + host: string; + port: number; +} + +export interface EmailMessage { + id: string; + messageId?: string; + subject: string; + from: EmailAddress[]; + to: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + date: Date; + flags: { + seen: boolean; + flagged: boolean; + answered: boolean; + deleted: boolean; + draft: boolean; + }; + preview?: string; + content?: string; + html?: string; + text?: string; + hasAttachments: boolean; + attachments?: EmailAttachment[]; + folder: string; + size?: number; + contentFetched: boolean; +} + +export interface EmailAddress { + name: string; + address: string; +} + +export interface EmailAttachment { + contentId?: string; + filename: string; + contentType: string; + size: number; + path?: string; + content?: string; +} + +export interface EmailListResult { + emails: EmailMessage[]; + totalEmails: number; + page: number; + perPage: number; + totalPages: number; + folder: string; + mailboxes: string[]; +} + +// Connection pool to reuse IMAP clients +const connectionPool: Record = {}; +const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes + +// Clean up idle connections periodically +setInterval(() => { + const now = Date.now(); + + Object.entries(connectionPool).forEach(([key, { client, lastUsed }]) => { + if (now - lastUsed > CONNECTION_TIMEOUT) { + console.log(`Closing idle IMAP connection for ${key}`); + client.logout().catch(err => { + console.error(`Error closing connection for ${key}:`, err); + }); + delete connectionPool[key]; + } + }); +}, 60 * 1000); // Check every minute + +/** + * Get IMAP connection for a user, reusing existing connections when possible + */ +export async function getImapConnection(userId: string): Promise { + // Get credentials from database + const credentials = await getUserEmailCredentials(userId); + if (!credentials) { + throw new Error('No email credentials found'); + } + + const connectionKey = `${userId}:${credentials.email}`; + const existingConnection = connectionPool[connectionKey]; + + // Return existing connection if available and connected + if (existingConnection) { + try { + if (existingConnection.client.usable) { + existingConnection.lastUsed = Date.now(); + return existingConnection.client; + } + } catch (error) { + console.warn(`Existing connection for ${connectionKey} is not usable, creating new connection`); + // Will create a new connection below + } + } + + // Create new connection + const client = new ImapFlow({ + host: credentials.host, + port: credentials.port, + secure: true, + auth: { + user: credentials.email, + pass: credentials.password, + }, + logger: false, + emitLogs: false, + tls: { + rejectUnauthorized: false + } + }); + + try { + await client.connect(); + + // Store in connection pool + connectionPool[connectionKey] = { + client, + lastUsed: Date.now() + }; + + return client; + } catch (error) { + throw new Error(`Failed to connect to IMAP server: ${error.message}`); + } +} + +/** + * Get user's email credentials from database + */ +export async function getUserEmailCredentials(userId: string): Promise { + const credentials = await prisma.mailCredentials.findUnique({ + where: { userId } + }); + + if (!credentials) { + return null; + } + + return { + email: credentials.email, + password: credentials.password, + host: credentials.host, + port: credentials.port + }; +} + +/** + * Save or update user's email credentials + */ +export async function saveUserEmailCredentials( + userId: string, + credentials: EmailCredentials +): Promise { + await prisma.mailCredentials.upsert({ + where: { userId }, + update: { + email: credentials.email, + password: credentials.password, + host: credentials.host, + port: credentials.port + }, + create: { + userId, + email: credentials.email, + password: credentials.password, + host: credentials.host, + port: credentials.port + } + }); +} + +/** + * Get list of emails for a user + */ +export async function getEmails( + userId: string, + folder: string = 'INBOX', + page: number = 1, + perPage: number = 20, + searchQuery: string = '' +): Promise { + const client = await getImapConnection(userId); + + try { + // Open mailbox + const mailboxData = await client.mailboxOpen(folder); + const totalMessages = mailboxData.exists; + + // Calculate range based on total messages + const endIdx = page * perPage; + const startIdx = (page - 1) * perPage + 1; + const from = Math.max(totalMessages - endIdx + 1, 1); + const to = Math.max(totalMessages - startIdx + 1, 1); + + // Empty result if no messages + if (totalMessages === 0 || from > to) { + const mailboxes = await getMailboxes(client); + return { + emails: [], + totalEmails: 0, + page, + perPage, + totalPages: 0, + folder, + mailboxes + }; + } + + // Search if needed + let messageIds: any[] = []; + if (searchQuery) { + messageIds = await client.search({ body: searchQuery }); + messageIds = messageIds.filter(id => id >= from && id <= to); + } else { + messageIds = Array.from({ length: to - from + 1 }, (_, i) => from + i); + } + + // Fetch messages + const emails: EmailMessage[] = []; + + for (const id of messageIds) { + try { + const message = await client.fetchOne(id, { + envelope: true, + flags: true, + bodyStructure: true, + internalDate: true, + size: true, + bodyParts: [ + { + query: { type: "text" }, + limit: 5000 + } + ] + }); + + if (!message) continue; + + const { envelope, flags, bodyStructure, internalDate, size, bodyParts } = message; + + // Extract preview content + let preview = ''; + if (bodyParts && bodyParts.length > 0) { + const textPart = bodyParts.find((part: any) => part.type === 'text/plain'); + const htmlPart = bodyParts.find((part: any) => part.type === 'text/html'); + const content = textPart?.content || htmlPart?.content || ''; + + if (typeof content === 'string') { + preview = content.substring(0, 150) + '...'; + } else if (Buffer.isBuffer(content)) { + preview = content.toString('utf-8', 0, 150) + '...'; + } + } + + // Process attachments + const attachments: EmailAttachment[] = []; + + const processAttachments = (node: any, path: Array = []) => { + if (!node) return; + + if (node.type === 'attachment') { + attachments.push({ + contentId: node.contentId, + filename: node.filename || 'attachment', + contentType: node.contentType, + size: node.size, + path: [...path, node.part].join('.') + }); + } + + if (node.childNodes) { + node.childNodes.forEach((child: any, index: number) => { + processAttachments(child, [...path, node.part || index + 1]); + }); + } + }; + + if (bodyStructure) { + processAttachments(bodyStructure); + } + + // Convert flags from Set to boolean checks + const flagsArray = Array.from(flags as Set); + + emails.push({ + id: id.toString(), + messageId: envelope.messageId, + subject: envelope.subject || "(No Subject)", + from: envelope.from.map((f: any) => ({ + name: f.name || f.address, + address: f.address, + })), + to: envelope.to.map((t: any) => ({ + name: t.name || t.address, + address: t.address, + })), + cc: (envelope.cc || []).map((c: any) => ({ + name: c.name || c.address, + address: c.address, + })), + bcc: (envelope.bcc || []).map((b: any) => ({ + name: b.name || b.address, + address: b.address, + })), + date: internalDate || new Date(), + flags: { + seen: flagsArray.includes("\\Seen"), + flagged: flagsArray.includes("\\Flagged"), + answered: flagsArray.includes("\\Answered"), + deleted: flagsArray.includes("\\Deleted"), + draft: flagsArray.includes("\\Draft"), + }, + hasAttachments: attachments.length > 0, + attachments, + size, + preview, + folder, + contentFetched: false + }); + } catch (error) { + console.error(`Error fetching message ${id}:`, error); + } + } + + // Sort by date, newest first + emails.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const mailboxes = await getMailboxes(client); + + return { + emails, + totalEmails: totalMessages, + page, + perPage, + totalPages: Math.ceil(totalMessages / perPage), + folder, + mailboxes + }; + } finally { + // Don't logout, keep connection in pool + if (folder !== 'INBOX') { + try { + await client.mailboxClose(); + } catch (error) { + console.error('Error closing mailbox:', error); + } + } + } +} + +/** + * Get a single email with full content + */ +export async function getEmailContent( + userId: string, + emailId: string, + folder: string = 'INBOX' +): Promise { + const client = await getImapConnection(userId); + + try { + await client.mailboxOpen(folder); + + const message = await client.fetchOne(emailId, { + source: true, + envelope: true, + flags: true + }); + + if (!message) { + throw new Error('Email not found'); + } + + const { source, envelope, flags } = message; + + // Parse the email content + const parsedEmail = await simpleParser(source.toString()); + + // Convert flags from Set to boolean checks + const flagsArray = Array.from(flags as Set); + + return { + id: emailId, + messageId: envelope.messageId, + subject: envelope.subject || "(No Subject)", + from: envelope.from.map((f: any) => ({ + name: f.name || f.address, + address: f.address, + })), + to: envelope.to.map((t: any) => ({ + name: t.name || t.address, + address: t.address, + })), + cc: (envelope.cc || []).map((c: any) => ({ + name: c.name || c.address, + address: c.address, + })), + bcc: (envelope.bcc || []).map((b: any) => ({ + name: b.name || b.address, + address: b.address, + })), + date: envelope.date || new Date(), + flags: { + seen: flagsArray.includes("\\Seen"), + flagged: flagsArray.includes("\\Flagged"), + answered: flagsArray.includes("\\Answered"), + deleted: flagsArray.includes("\\Deleted"), + draft: flagsArray.includes("\\Draft"), + }, + hasAttachments: parsedEmail.attachments?.length > 0, + attachments: parsedEmail.attachments?.map(att => ({ + filename: att.filename || 'attachment', + contentType: att.contentType, + size: att.size || 0 + })), + html: parsedEmail.html || undefined, + text: parsedEmail.text || undefined, + content: parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || '', + folder, + contentFetched: true + }; + } finally { + try { + await client.mailboxClose(); + } catch (error) { + console.error('Error closing mailbox:', error); + } + } +} + +/** + * Mark an email as read or unread + */ +export async function markEmailReadStatus( + userId: string, + emailId: string, + isRead: boolean, + folder: string = 'INBOX' +): Promise { + const client = await getImapConnection(userId); + + try { + await client.mailboxOpen(folder); + + if (isRead) { + await client.messageFlagsAdd(emailId, ['\\Seen']); + } else { + await client.messageFlagsRemove(emailId, ['\\Seen']); + } + + return true; + } catch (error) { + console.error(`Error marking email ${emailId} as ${isRead ? 'read' : 'unread'}:`, error); + return false; + } finally { + try { + await client.mailboxClose(); + } catch (error) { + console.error('Error closing mailbox:', error); + } + } +} + +/** + * Send an email + */ +export async function sendEmail( + userId: string, + emailData: { + to: string; + cc?: string; + bcc?: string; + subject: string; + body: string; + attachments?: Array<{ + name: string; + content: string; + type: string; + }>; + } +): Promise<{ success: boolean; messageId?: string; error?: string }> { + const credentials = await getUserEmailCredentials(userId); + + if (!credentials) { + return { + success: false, + error: 'No email credentials found' + }; + } + + // Create SMTP transporter + const transporter = nodemailer.createTransport({ + host: 'smtp.infomaniak.com', // Using Infomaniak SMTP server + port: 587, + secure: false, + auth: { + user: credentials.email, + pass: credentials.password, + }, + tls: { + rejectUnauthorized: false + } + }); + + try { + // Verify connection + await transporter.verify(); + + // Prepare email options + const mailOptions = { + from: credentials.email, + to: emailData.to, + cc: emailData.cc || undefined, + bcc: emailData.bcc || undefined, + subject: emailData.subject || '(No subject)', + html: emailData.body, + attachments: emailData.attachments?.map(file => ({ + filename: file.name, + content: file.content, + contentType: file.type + })) || [] + }; + + // Send email + const info = await transporter.sendMail(mailOptions); + + return { + success: true, + messageId: info.messageId + }; + } catch (error) { + console.error('Error sending email:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Get list of mailboxes (folders) + */ +export async function getMailboxes(client: ImapFlow): Promise { + try { + const list = await client.list(); + return list.map(mailbox => mailbox.path); + } catch (error) { + console.error('Error listing mailboxes:', error); + return []; + } +} + +/** + * Test email connection with given credentials + */ +export async function testEmailConnection(credentials: EmailCredentials): Promise { + const client = new ImapFlow({ + host: credentials.host, + port: credentials.port, + secure: true, + auth: { + user: credentials.email, + pass: credentials.password, + }, + logger: false, + tls: { + rejectUnauthorized: false + } + }); + + try { + await client.connect(); + await client.mailboxOpen('INBOX'); + return true; + } catch (error) { + console.error('Connection test failed:', error); + return false; + } finally { + try { + await client.logout(); + } catch (e) { + // Ignore logout errors + } + } +} \ No newline at end of file