panel 2 courier api restore

This commit is contained in:
alma 2025-04-26 09:14:44 +02:00
parent 079d0a484b
commit d73bf3b773
11 changed files with 891 additions and 1046 deletions

View File

@ -1,11 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; 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 // Global cache reference (will be moved to a proper cache solution in the future)
// This is a hack - ideally we'd use a shared module or Redis for caching
declare global { declare global {
var emailListCache: { [key: string]: { data: any, timestamp: number } }; 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 }); 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 { 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 // If not found in INBOX, try to find it in other common folders
const mailboxes = await client.list(); if (!success) {
let emailFolder = 'INBOX'; // Default to INBOX const commonFolders = ['Sent', 'Drafts', 'Trash', 'Spam', 'Junk'];
let foundEmail = false;
for (const folder of commonFolders) {
// Search through folders to find the email success = await markEmailReadStatus(session.user.id, emailId, true, folder);
for (const box of mailboxes) { if (success) {
try { // If found in a different folder, invalidate that folder's cache
await client.mailboxOpen(box.path); invalidateCache(session.user.id, folder);
// 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']);
}
break; 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( return NextResponse.json(
{ error: 'Email not found' }, { error: 'Email not found in any folder' },
{ status: 404 } { status: 404 }
); );
} }
// Invalidate the cache for this folder
invalidateCache(session.user.id, emailFolder);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} finally { } catch (error) {
try { console.error('Error marking email as read:', error);
await client.logout(); return NextResponse.json(
} catch (e) { { error: 'Failed to mark email as read' },
console.error('Error during logout:', e); { status: 500 }
} );
} }
} catch (error) { } catch (error) {
console.error('Error marking email as read:', error); console.error('Error marking email as read:', error);

View File

@ -7,14 +7,9 @@
*/ */
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma'; import { getEmailContent, markEmailReadStatus } from '@/lib/services/email-service';
import { simpleParser } from 'mailparser';
// Simple in-memory cache for email content
const emailContentCache = new Map<string, any>();
export async function GET( export async function GET(
request: Request, 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 { searchParams } = new URL(request.url);
const folder = searchParams.get("folder") || "INBOX"; const folder = searchParams.get("folder") || "INBOX";
// Create IMAP client
let imapClient: any = null;
try { try {
imapClient = new ImapFlow({ // Use the email service to fetch the email content
host: credentials.host, const email = await getEmailContent(session.user.id, id, folder);
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;
// Parse the full email content // Return only what's needed for displaying the email
const parsedEmail = await simpleParser(source.toString());
// Return only the content
return NextResponse.json({ return NextResponse.json({
id, id,
subject: envelope.subject, subject: email.subject,
content: parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || '', content: email.content,
contentFetched: true contentFetched: true
}); });
} catch (error: any) { } catch (error: any) {
@ -106,16 +52,6 @@ export async function GET(
{ error: "Failed to fetch email content", message: error.message }, { error: "Failed to fetch email content", message: error.message },
{ status: 500 } { 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) { } catch (error: any) {
console.error("Error in GET:", error); 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 { searchParams } = new URL(request.url);
const folder = searchParams.get("folder") || "INBOX"; const folder = searchParams.get("folder") || "INBOX";
// Create IMAP client // Use the email service to mark the email
let imapClient: any = null; const success = await markEmailReadStatus(
try { session.user.id,
imapClient = new ImapFlow({ id,
host: credentials.host, action === 'mark-read',
port: credentials.port, folder
secure: true, );
auth: {
user: credentials.email,
pass: credentials.password,
},
logger: false,
});
await imapClient.connect(); if (!success) {
// 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);
return NextResponse.json( 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 } { 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) { } catch (error: any) {
console.error("Error in POST:", error); console.error("Error in POST:", error);
return NextResponse.json( return NextResponse.json(

View File

@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma'; import { getUserEmailCredentials } from '@/lib/services/email-service';
export async function GET() { export async function GET() {
try { try {
@ -24,17 +24,8 @@ export async function GET() {
}, { status: 401 }); }, { status: 401 });
} }
// Fetch mail credentials for this user // Fetch mail credentials for this user using our service
const mailCredentials = await prisma.mailCredentials.findUnique({ const mailCredentials = await getUserEmailCredentials(userId);
where: {
userId: userId
},
select: {
email: true,
host: true,
port: true
}
});
// If no credentials found // If no credentials found
if (!mailCredentials) { if (!mailCredentials) {
@ -44,10 +35,14 @@ export async function GET() {
}, { status: 404 }); }, { status: 404 });
} }
// Return the credentials // Return the credentials (excluding password)
console.log(`Successfully retrieved mail credentials for user ID: ${userId}`); console.log(`Successfully retrieved mail credentials for user ID: ${userId}`);
return NextResponse.json({ return NextResponse.json({
credentials: mailCredentials credentials: {
email: mailCredentials.email,
host: mailCredentials.host,
port: mailCredentials.port
}
}); });
} catch (error) { } catch (error) {

View File

@ -1,107 +1,65 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; 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) { export async function POST(request: Request) {
try { try {
console.log('Processing login POST request'); // Authenticate user
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
console.log('No authenticated session found');
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
// Get credentials from request
const { email, password, host, port } = await request.json(); 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) { if (!email || !password || !host || !port) {
console.log('Missing required login fields');
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields' }, { error: 'Missing required fields' },
{ status: 400 } { status: 400 }
); );
} }
// Test IMAP connection // Test connection before saving
console.log('Testing IMAP connection to:', host, port); const connectionSuccess = await testEmailConnection({
const client = new ImapFlow({ email,
host: host, password,
port: parseInt(port), host,
secure: true, port: parseInt(port)
auth: {
user: email,
pass: password,
},
logger: false,
emitLogs: false,
tls: {
rejectUnauthorized: false
}
}); });
try { if (!connectionSuccess) {
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 }
);
}
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to connect to email server' }, { error: 'Failed to connect to email server. Please check your credentials.' },
{ status: 500 } { 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) { } catch (error) {
console.error('Error in login handler:', error); console.error('Error in login handler:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'An unexpected error occurred' }, {
error: 'An unexpected error occurred',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 } { status: 500 }
); );
} }
@ -109,37 +67,31 @@ export async function POST(request: Request) {
export async function GET() { export async function GET() {
try { try {
console.log('Fetching mail credentials'); // Authenticate user
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
console.log('No authenticated session found');
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
const credentials = await prisma.mailCredentials.findUnique({ // Get user credentials from database
where: { const credentials = await getUserEmailCredentials(session.user.id);
userId: session.user.id
},
select: {
email: true,
host: true,
port: true
}
});
if (!credentials) { if (!credentials) {
console.log('No mail credentials found for user');
return NextResponse.json( return NextResponse.json(
{ error: 'No stored credentials found' }, { error: 'No stored credentials found' },
{ status: 404 } { status: 404 }
); );
} }
console.log('Credentials found for:', credentials.email); // Return credentials without the password
return NextResponse.json(credentials); return NextResponse.json({
email: credentials.email,
host: credentials.host,
port: credentials.port
});
} catch (error) { } catch (error) {
console.error('Error fetching credentials:', error); console.error('Error fetching credentials:', error);
return NextResponse.json( return NextResponse.json(

View File

@ -1,88 +1,21 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { ImapFlow } from 'imapflow';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma'; import { getEmails } from '@/lib/services/email-service';
import { simpleParser } from 'mailparser';
// Type definitions // Simple in-memory cache (will be removed in a future update)
interface EmailCacheEntry { interface EmailCacheEntry {
data: any; data: any;
timestamp: number; timestamp: number;
} }
interface CredentialsCacheEntry { // Cache for 1 minute only
client: any; // Use any for ImapFlow to avoid type issues const CACHE_TTL = 60 * 1000;
timestamp: number;
}
// Email cache structure
const emailListCache: Record<string, EmailCacheEntry> = {}; const emailListCache: Record<string, EmailCacheEntry> = {};
const credentialsCache: Record<string, CredentialsCacheEntry> = {};
// 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<T>(operation: () => Promise<T>, maxAttempts = 3, delay = 1000): Promise<T> {
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) { export async function GET(request: Request) {
try { try {
// Authenticate user
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user?.id) { if (!session || !session.user?.id) {
return NextResponse.json( return NextResponse.json(
@ -91,310 +24,45 @@ export async function GET(request: Request) {
); );
} }
// Get mail credentials // Extract query parameters
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 { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1"); const page = parseInt(searchParams.get("page") || "1");
const perPage = parseInt(searchParams.get("perPage") || "20"); const perPage = parseInt(searchParams.get("perPage") || "20");
const folder = searchParams.get("folder") || "INBOX"; const folder = searchParams.get("folder") || "INBOX";
const searchQuery = searchParams.get("search") || ""; 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 cacheKey = `${session.user.id}:${folder}:${page}:${perPage}:${searchQuery}`;
const now = Date.now();
const cachedEmails = emailListCache[cacheKey]; const cachedEmails = emailListCache[cacheKey];
if (cachedEmails) { if (cachedEmails && now - cachedEmails.timestamp < CACHE_TTL) {
console.log(`Using cached emails for ${cacheKey}`); console.log(`Using cached emails for ${cacheKey}`);
return NextResponse.json(cachedEmails.data); 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 // Use the email service to fetch emails
const cacheCredKey = `credentials:${session.user.id}`; const emailsResult = await getEmails(
let imapClient: any = credentialsCache[cacheCredKey]?.client || null; session.user.id,
folder,
page,
perPage,
searchQuery
);
if (!imapClient) { // Cache the results
// Create IMAP client emailListCache[cacheKey] = {
const connectWithRetry = async (retries = 3, delay = 1000): Promise<any> => { data: emailsResult,
try { timestamp: now
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 [];
}
}; };
// Setup paging return NextResponse.json(emailsResult);
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<string | number> = []) => {
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<string>);
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();
}
}
} catch (error: any) { } catch (error: any) {
console.error("Error in GET:", error); console.error("Error fetching emails:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error", message: error.message }, { error: "Failed to fetch emails", message: error.message },
{ status: 500 } { status: 500 }
); );
} }
@ -407,30 +75,25 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { emailId, folderName, action } = await request.json(); const { emailId, folderName } = await request.json();
if (!emailId) { if (!emailId) {
return NextResponse.json({ error: 'Missing emailId parameter' }, { status: 400 }); 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; const userId = session.user.id;
Object.keys(emailListCache).forEach(key => {
// If folder is specified, only invalidate that folder's cache if (folderName) {
if (folderName) {
Object.keys(emailListCache).forEach(key => {
if (key.includes(`${userId}:${folderName}`)) { if (key.includes(`${userId}:${folderName}`)) {
delete emailListCache[key]; delete emailListCache[key];
} }
}); } else {
} else {
// Otherwise invalidate all cache entries for this user
Object.keys(emailListCache).forEach(key => {
if (key.startsWith(`${userId}:`)) { if (key.startsWith(`${userId}:`)) {
delete emailListCache[key]; delete emailListCache[key];
} }
}); }
} });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {

View File

@ -1,100 +1,53 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma'; import { sendEmail } from '@/lib/services/email-service';
import nodemailer from 'nodemailer';
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
console.log('Starting email send process...'); // Authenticate user
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
console.log('No session found');
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
// Get credentials from database // Parse request body
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
const { to, cc, bcc, subject, body, attachments } = await request.json(); 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) { if (!to) {
console.log('No recipient specified');
return NextResponse.json( return NextResponse.json(
{ error: 'Recipient is required' }, { error: 'Recipient is required' },
{ status: 400 } { status: 400 }
); );
} }
// Create SMTP transporter with Infomaniak SMTP settings // Use email service to send the email
console.log('Creating SMTP transporter...'); const result = await sendEmail(session.user.id, {
const transporter = nodemailer.createTransport({ to,
host: 'smtp.infomaniak.com', cc,
port: 587, bcc,
secure: false, subject,
auth: { body,
user: credentials.email, attachments
pass: credentials.password,
},
tls: {
rejectUnauthorized: false
},
debug: true // Enable debug logging
}); });
// Verify SMTP connection if (!result.success) {
console.log('Verifying SMTP connection...'); return NextResponse.json(
try { {
await transporter.verify(); error: 'Failed to send email',
console.log('SMTP connection verified successfully'); details: result.error
} catch (error) { },
console.error('SMTP connection verification failed:', error); { status: 500 }
throw error; );
} }
// 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({ return NextResponse.json({
success: true, success: true,
messageId: info.messageId messageId: result.messageId
}); });
} catch (error) { } catch (error) {
console.error('Error sending email:', error); console.error('Error sending email:', error);

View File

@ -1,88 +1,22 @@
import { NextResponse } from 'next/server'; 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 { * This route is deprecated. It redirects to the new courrier API endpoint.
const session = await getServerSession(authOptions); * @deprecated Use the /api/courrier endpoint instead
if (!session?.user?.id) { */
return NextResponse.json( export async function GET(request: Request) {
{ error: 'Unauthorized' }, console.warn('Deprecated: /api/mail route is being used. Update your code to use /api/courrier instead.');
{ status: 401 }
); // Extract query parameters
} const url = new URL(request.url);
// Get credentials from database // Redirect to the new API endpoint
const credentials = await prisma.mailCredentials.findUnique({ const redirectUrl = new URL('/api/courrier', url.origin);
where: {
userId: session.user.id // Copy all search parameters
} url.searchParams.forEach((value, key) => {
}); redirectUrl.searchParams.set(key, value);
});
if (!credentials) {
return NextResponse.json( return NextResponse.redirect(redirectUrl.toString());
{ 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 }
);
}
} }

View File

@ -1,103 +1,32 @@
import { NextResponse } from 'next/server'; 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) { 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 { try {
const credentials = getStoredCredentials(); // Clone the request body
if (!credentials) { const body = await request.json();
return NextResponse.json(
{ error: 'No stored credentials found' }, // Make a new request to the courrier API
{ status: 401 } const newRequest = new Request(new URL('/api/courrier/send', request.url).toString(), {
); method: 'POST',
} headers: {
'Content-Type': 'application/json'
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,
}, },
body: JSON.stringify(body)
}); });
// Prepare email options // Forward the request
const mailOptions = { const response = await fetch(newRequest);
from: credentials.email, const data = await response.json();
to,
cc, return NextResponse.json(data, { status: response.status });
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 });
} catch (error) { } catch (error) {
console.error('Error sending email:', error); console.error('Error forwarding to courrier/send:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to send email' }, { error: 'Failed to send email' },
{ status: 500 } { status: 500 }

View File

@ -164,19 +164,22 @@ export default function ComposeEmail({
let formattedContent = ''; let formattedContent = '';
if (forwardFrom) { if (forwardFrom) {
formattedContent = ` // Create a clean header for the forwarded email
<div class="forwarded-message"> const headerHtml = `
<p>---------- Forwarded message ---------</p> <div style="border-bottom: 1px solid #e2e2e2; margin-bottom: 15px; padding-bottom: 15px; font-family: Arial, sans-serif;">
<p>From: ${decoded.from || ''}</p> <p style="margin: 4px 0;">---------- Forwarded message ---------</p>
<p>Date: ${formatDate(decoded.date)}</p> <p style="margin: 4px 0;"><b>From:</b> ${decoded.from || ''}</p>
<p>Subject: ${decoded.subject || ''}</p> <p style="margin: 4px 0;"><b>Date:</b> ${formatDate(decoded.date)}</p>
<p>To: ${decoded.to || ''}</p> <p style="margin: 4px 0;"><b>Subject:</b> ${decoded.subject || ''}</p>
<br> <p style="margin: 4px 0;"><b>To:</b> ${decoded.to || ''}</p>
<div class="email-content prose prose-sm max-w-none dark:prose-invert">
${decoded.html || `<pre>${decoded.text || ''}</pre>`}
</div>
</div> </div>
`; `;
// Use the original HTML as-is without DOMPurify or any modification
formattedContent = `
${headerHtml}
${decoded.html || decoded.text || 'No content available'}
`;
} else { } else {
formattedContent = ` formattedContent = `
<div class="quoted-message"> <div class="quoted-message">

View File

@ -3,11 +3,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { RefreshCw, Mail } from "lucide-react"; import { RefreshCw, MessageSquare, Mail, MailOpen, Loader2 } from "lucide-react";
import { useSession } from "next-auth/react"; import Link from 'next/link';
import { formatDistance } from 'date-fns/formatDistance';
import { fr } from 'date-fns/locale/fr';
import { useRouter } from "next/navigation";
interface Email { interface Email {
id: string; id: string;
@ -28,161 +25,125 @@ interface EmailResponse {
export function Email() { export function Email() {
const [emails, setEmails] = useState<Email[]>([]); const [emails, setEmails] = useState<Email[]>([]);
const [mailUrl, setMailUrl] = useState<string | null>(null); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false); const [mailUrl, setMailUrl] = useState<string | null>(null);
const { data: session, status } = useSession();
const router = useRouter(); useEffect(() => {
fetchEmails();
}, []);
const fetchEmails = async (isRefresh = false) => { const fetchEmails = async (isRefresh = false) => {
if (status !== 'authenticated') { setLoading(true);
setError('Please sign in to view emails'); setError(null);
setLoading(false);
setRefreshing(false);
return;
}
if (isRefresh) setRefreshing(true);
if (!isRefresh) setLoading(true);
try { try {
const response = await fetch('/api/mail'); const response = await fetch('/api/courrier?folder=INBOX&page=1&perPage=5');
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); throw new Error('Failed to fetch emails');
throw new Error(errorData.error || 'Failed to fetch emails');
} }
const data = await response.json(); const data = await response.json();
if (data.error) { 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');
} }
} catch (error) {
const validatedEmails = data.emails.map((email: any) => ({ console.error('Error fetching emails:', error);
id: email.id || Date.now().toString(), setError('Failed to load emails');
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');
setEmails([]); setEmails([]);
} finally { } finally {
setLoading(false); 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) => { const formatDate = (dateString: string) => {
try { try {
const date = new Date(dateString); const date = new Date(dateString);
return formatDistance(date, new Date(), { return new Intl.DateTimeFormat('fr-FR', {
addSuffix: true, month: 'short',
locale: fr day: 'numeric'
}); }).format(date);
} catch (err) { } catch (e) {
return dateString; return '';
} }
}; };
if (status === 'loading' || loading) {
return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-gray-600" />
<span>Courrier</span>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-gray-400" />
</div>
</CardContent>
</Card>
);
}
return ( return (
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full"> <Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-x-4 border-b border-gray-100"> <CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
<CardTitle className="text-lg font-semibold text-gray-800"> <CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<div className="flex items-center gap-2"> <MessageSquare className="h-5 w-5 text-gray-600" />
<Mail className="h-5 w-5 text-gray-600" /> Emails non lus
<span>Courrier</span>
</div>
</CardTitle> </CardTitle>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => fetchEmails(true)} onClick={() => fetchEmails(true)}
disabled={refreshing} disabled={loading}
className={`${refreshing ? 'animate-spin' : ''} text-gray-600 hover:text-gray-900`}
> >
<RefreshCw className="h-4 w-4" /> {loading ?
<Loader2 className="h-4 w-4 animate-spin" /> :
<RefreshCw className="h-4 w-4" />
}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="p-3"> <CardContent className="p-4">
{error ? ( {error ? (
<p className="text-center text-red-500">{error}</p> <div className="text-center py-4 text-gray-500">
{error}
</div>
) : loading && emails.length === 0 ? (
<div className="text-center py-6 flex flex-col items-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
<p className="text-gray-500">Chargement des emails...</p>
</div>
) : emails.length === 0 ? (
<div className="text-center py-6">
<p className="text-gray-500">Aucun email non lu</p>
</div>
) : ( ) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto"> <div className="space-y-3">
{emails.length === 0 ? ( {emails.map((email) => (
<p className="text-center text-gray-500"> <div key={email.id} className="flex items-start gap-3 py-1 border-b border-gray-100 last:border-0">
{loading ? 'Loading emails...' : 'No unread emails'} <div className="pt-1">
</p> {email.read ?
) : ( <MailOpen className="h-4 w-4 text-gray-400" /> :
emails.map((email) => ( <Mail className="h-4 w-4 text-blue-500" />
<div }
key={email.id}
className="p-2 hover:bg-gray-50/50 rounded-lg transition-colors cursor-pointer"
onClick={() => router.push('/mail')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600 truncate max-w-[60%]" title={email.fromName || email.from}>
{email.fromName || email.from}
</span>
<div className="flex items-center space-x-2">
{!email.read && <span className="w-1.5 h-1.5 bg-blue-600 rounded-full"></span>}
<span className="text-xs text-gray-500">{formatDate(email.date)}</span>
</div>
</div>
<p className="text-sm text-gray-800 truncate">{email.subject}</p>
</div> </div>
)) <div className="flex-1 min-w-0">
<div className="flex justify-between">
<p className="font-medium truncate" style={{maxWidth: '180px'}}>{email.fromName || email.from.split('@')[0]}</p>
<p className="text-xs text-gray-500">{formatDate(email.date)}</p>
</div>
<p className="text-sm text-gray-700 truncate">{email.subject}</p>
</div>
</div>
))}
{mailUrl && (
<div className="pt-2">
<Link href={mailUrl} className="text-sm text-blue-600 hover:text-blue-800">
Voir tous les emails
</Link>
</div>
)} )}
</div> </div>
)} )}

View File

@ -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<string, { client: ImapFlow; lastUsed: number }> = {};
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<ImapFlow> {
// 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<EmailCredentials | null> {
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<void> {
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<EmailListResult> {
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<string | number> = []) => {
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<string>);
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<EmailMessage> {
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<string>);
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<boolean> {
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<string[]> {
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<boolean> {
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
}
}
}