courrier multi account

This commit is contained in:
alma 2025-04-27 18:02:22 +02:00
parent 4d01953b7a
commit c787d6a1a5
7 changed files with 264 additions and 92 deletions

View File

@ -83,70 +83,80 @@ export async function GET(request: Request) {
);
}
} else {
// Get folders for all accounts
// Get all accounts for this user
const accounts = await prisma.mailCredentials.findMany({
where: { userId },
select: {
id: true,
email: true
email: true,
password: true,
host: true,
port: true
}
});
// For demo purposes, rather than connecting to all accounts,
// get the default set of folders from the first cached account
const credentials = await getCachedEmailCredentials(userId);
if (!credentials) {
if (accounts.length === 0) {
return NextResponse.json({
accounts: accounts.map(account => ({
id: account.id,
email: account.email,
folders: ['INBOX', 'Sent', 'Drafts', 'Trash'] // Fallback folders
}))
accounts: []
});
}
// 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,
tls: {
rejectUnauthorized: false
}
});
try {
await client.connect();
// Get folders
const folders = await getMailboxes(client);
// Close connection
await client.logout();
return NextResponse.json({
accounts: accounts.map(account => ({
// Fetch folders for each account individually
const accountsWithFolders = await Promise.all(accounts.map(async (account) => {
try {
// Connect to IMAP server for this specific account
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: true,
auth: {
user: account.email,
pass: account.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
await client.connect();
// Get folders for this account
const folders = await getMailboxes(client);
// Close connection
await client.logout();
// Add display_name and color from database
const metadata = await prisma.$queryRaw`
SELECT display_name, color
FROM "MailCredentials"
WHERE id = ${account.id}
`;
const displayMetadata = Array.isArray(metadata) && metadata.length > 0 ? metadata[0] : {};
return {
id: account.id,
email: account.email,
display_name: displayMetadata.display_name || account.email,
color: displayMetadata.color || "#0082c9",
folders
}))
});
} catch (error) {
return NextResponse.json({
accounts: accounts.map(account => ({
};
} catch (error) {
console.error(`Error fetching folders for account ${account.email}:`, error);
// Return fallback folders on error
return {
id: account.id,
email: account.email,
folders: ['INBOX', 'Sent', 'Drafts', 'Trash'] // Fallback folders on error
})),
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
}
}));
return NextResponse.json({
accounts: accountsWithFolders
});
}
} catch (error) {
return NextResponse.json(

View File

@ -35,25 +35,28 @@ export async function GET(request: Request) {
const perPage = parseInt(searchParams.get("perPage") || "20");
const folder = searchParams.get("folder") || "INBOX";
const searchQuery = searchParams.get("search") || "";
const accountId = searchParams.get("accountId") || "";
// Try to get from Redis cache first, but only if it's not a search query
if (!searchQuery) {
const cacheKey = accountId ? `${session.user.id}:${accountId}:${folder}` : `${session.user.id}:${folder}`;
const cachedEmails = await getCachedEmailList(session.user.id, folder, page, perPage);
if (cachedEmails) {
console.log(`Using Redis cached emails for ${session.user.id}:${folder}:${page}:${perPage}`);
console.log(`Using Redis cached emails for ${cacheKey}:${page}:${perPage}`);
return NextResponse.json(cachedEmails);
}
}
console.log(`Redis cache miss for ${session.user.id}:${folder}:${page}:${perPage}, fetching emails from IMAP`);
// Use the email service to fetch emails
// Use the email service to fetch emails, passing the accountId if provided
const emailsResult = await getEmails(
session.user.id,
folder,
page,
perPage,
searchQuery
searchQuery,
accountId
);
// The result is already cached in the getEmails function

View File

@ -1,15 +1,71 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getUserEmailCredentials } from '@/lib/services/email-service';
import { getUserEmailCredentials, getMailboxes } from '@/lib/services/email-service';
import { prefetchUserEmailData } from '@/lib/services/prefetch-service';
import { getCachedEmailCredentials, getRedisStatus, warmupRedisCache, getCachedImapSession, cacheImapSession } from '@/lib/redis';
import { prisma } from '@/lib/prisma';
import { ImapFlow } from 'imapflow';
// Keep track of last prefetch time for each user
const lastPrefetchMap = new Map<string, number>();
const PREFETCH_COOLDOWN_MS = 30000; // 30 seconds cooldown between prefetches
// Cache to store account folders to avoid repeated calls to the IMAP server
const accountFoldersCache = new Map<string, { folders: string[], timestamp: number }>();
const FOLDERS_CACHE_TTL = 5 * 60 * 1000; // 5 minute cache
/**
* Get folders for a specific account
*/
async function getAccountFolders(accountId: string, account: any): Promise<string[]> {
// Check cache first
const cacheKey = `folders:${accountId}`;
const cachedData = accountFoldersCache.get(cacheKey);
const now = Date.now();
if (cachedData && (now - cachedData.timestamp < FOLDERS_CACHE_TTL)) {
return cachedData.folders;
}
try {
// Connect to IMAP server for this account
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: true,
auth: {
user: account.email,
pass: account.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
await client.connect();
// Get folders for this account
const folders = await getMailboxes(client);
// Close connection
await client.logout();
// Cache the result
accountFoldersCache.set(cacheKey, {
folders,
timestamp: now
});
return folders;
} catch (error) {
console.error(`Error fetching folders for account ${account.email}:`, error);
// Return fallback folders on error
return ['INBOX', 'Sent', 'Drafts', 'Trash'];
}
}
/**
* This endpoint is called when the app initializes to check if the user has email credentials
* and to start prefetching email data in the background if they do
@ -63,6 +119,7 @@ export async function GET() {
select: {
id: true,
email: true,
password: true,
host: true,
port: true
}
@ -84,10 +141,17 @@ export async function GET() {
// Cast the raw result to an array and get the first item
const metadata = Array.isArray(rawAccount) ? rawAccount[0] : rawAccount;
// Get folders for this specific account
const accountFolders = await getAccountFolders(account.id, {
...account,
...metadata
});
return {
...account,
display_name: metadata?.display_name || account.email,
color: metadata?.color || "#0082c9"
color: metadata?.color || "#0082c9",
folders: accountFolders
};
}));
@ -141,7 +205,7 @@ export async function GET() {
});
}
// Return all accounts information
// Return all accounts information with their specific folders
return NextResponse.json({
authenticated: true,
hasEmailCredentials: true,
@ -150,13 +214,13 @@ export async function GET() {
prefetchStarted,
credentialsSource,
lastVisit: cachedSession?.lastVisit,
mailboxes: cachedSession?.mailboxes || [],
mailboxes: cachedSession?.mailboxes || [], // For backward compatibility
allAccounts: accountsWithMetadata.map(account => ({
id: account.id,
email: account.email,
display_name: account.display_name || account.email,
color: account.color || "#0082c9",
folders: cachedSession?.mailboxes || [] // Add folders directly to each account
folders: account.folders || [] // Use account-specific folders
}))
});
} catch (dbError) {

View File

@ -402,7 +402,13 @@ export default function CourrierPage() {
// Also prefetch additional pages to make scrolling smoother
if (session?.user?.id) {
// Prefetch next 2 pages beyond the current next page
prefetchFolderEmails(session.user.id, currentFolder, 2, nextPage + 1).catch(err => {
prefetchFolderEmails(
session.user.id,
currentFolder,
2,
nextPage + 1,
selectedAccount?.id
).catch(err => {
console.error(`Error prefetching additional pages for ${currentFolder}:`, err);
});
}
@ -465,10 +471,16 @@ export default function CourrierPage() {
};
// Handle mailbox change with prefetching
const handleMailboxChange = (folder: string) => {
const handleMailboxChange = (folder: string, accountId?: string) => {
// Reset to page 1 when changing folders
setPage(1);
// If we have a specific accountId, store it with the folder
if (accountId) {
// Store the current account ID with the folder change
console.log(`Changing folder to ${folder} for account ${accountId}`);
}
// Change folder in the state
changeFolder(folder);
setCurrentView(folder);
@ -476,7 +488,7 @@ export default function CourrierPage() {
// Start prefetching additional pages for this folder
if (session?.user?.id && folder) {
// First two pages are most important - prefetch immediately
prefetchFolderEmails(session.user.id, folder, 3).catch(err => {
prefetchFolderEmails(session.user.id, folder, 3, 1, accountId).catch(err => {
console.error(`Error prefetching ${folder}:`, err);
});
}
@ -795,23 +807,51 @@ export default function CourrierPage() {
if (selectedAccount?.id === account.id) {
setShowFolders(!showFolders);
} else {
// When selecting a new account, make sure we show its folders
setShowFolders(true);
// Reset to the inbox folder of the new account by default
if (account.folders && account.folders.length > 0) {
// Find INBOX or default to first folder
const inboxFolder = account.folders.find(f =>
f.toLowerCase() === 'inbox') || account.folders[0];
// Change to this account's inbox folder
handleMailboxChange(inboxFolder, account.id);
}
}
setSelectedAccount(account);
// Force the account to have folders if it doesn't already
if (account.id !== 'all-accounts' && (!account.folders || account.folders.length === 0)) {
const accountWithFolders = {
...account,
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
};
setSelectedAccount(accountWithFolders);
// Also update the account in the accounts array
const newAccounts = accounts.map(a =>
a.id === account.id ? accountWithFolders : a
);
setAccounts(newAccounts);
// Fetch folders for this account if not already available
fetch(`/api/courrier/account-folders?accountId=${account.id}`)
.then(res => res.json())
.then(data => {
if (data.folders && Array.isArray(data.folders)) {
const accountWithFolders = {
...account,
folders: data.folders
};
setSelectedAccount(accountWithFolders);
// Also update the account in the accounts array
const newAccounts = accounts.map(a =>
a.id === account.id ? accountWithFolders : a
);
setAccounts(newAccounts);
}
})
.catch(err => {
console.error(`Error fetching folders for account ${account.id}:`, err);
// Use default folders if API fails
const accountWithFolders = {
...account,
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
};
setSelectedAccount(accountWithFolders);
});
}
}}
>
@ -842,7 +882,8 @@ export default function CourrierPage() {
}`}
onClick={(e) => {
e.stopPropagation();
handleMailboxChange(folder);
// Pass the account ID along with the folder name
handleMailboxChange(folder, selectedAccount?.id);
}}
>
<div className="flex items-center justify-between w-full gap-1.5">

View File

@ -77,7 +77,7 @@ export const useCourrier = () => {
const { toast } = useToast();
// Load emails from the server
const loadEmails = useCallback(async (isLoadMore = false) => {
const loadEmails = useCallback(async (isLoadMore = false, accountId?: string) => {
if (!session?.user?.id) return;
setIsLoading(true);
@ -87,6 +87,22 @@ export const useCourrier = () => {
const currentRequestPage = page;
try {
// Build query params
const queryParams = new URLSearchParams({
folder: currentFolder,
page: currentRequestPage.toString(),
perPage: perPage.toString()
});
if (searchQuery) {
queryParams.set('search', searchQuery);
}
// Add accountId if provided
if (accountId) {
queryParams.set('accountId', accountId);
}
// First try Redis cache with low timeout
const cachedEmails = await getCachedEmailsWithTimeout(session.user.id, currentFolder, currentRequestPage, perPage, 100);
if (cachedEmails) {
@ -157,17 +173,6 @@ export const useCourrier = () => {
return;
}
// Build query params
const queryParams = new URLSearchParams({
folder: currentFolder,
page: currentRequestPage.toString(),
perPage: perPage.toString()
});
if (searchQuery) {
queryParams.set('search', searchQuery);
}
// Fetch emails from API
const response = await fetch(`/api/courrier?${queryParams.toString()}`);
@ -513,12 +518,16 @@ export const useCourrier = () => {
}
}, [emails, selectedEmailIds]);
// Change the current folder
const changeFolder = useCallback((folder: MailFolder) => {
// Change folder and load emails
const changeFolder = useCallback((folder: string, accountId?: string) => {
setCurrentFolder(folder);
setPage(1);
setPage(1); // Reset to first page when changing folders
setSelectedEmail(null);
setSelectedEmailIds([]);
// Load the emails for this folder
// The loadEmails function will be called by the useEffect above
// due to the dependency on currentFolder
}, []);
// Search emails

View File

@ -234,20 +234,64 @@ export async function getEmails(
folder: string = 'INBOX',
page: number = 1,
perPage: number = 20,
searchQuery: string = ''
searchQuery: string = '',
accountId?: string
): Promise<EmailListResult> {
// Try to get from cache first
if (!searchQuery) {
const cacheKey = accountId ? `${userId}:${accountId}:${folder}` : `${userId}:${folder}`;
const cachedResult = await getCachedEmailList(userId, folder, page, perPage);
if (cachedResult) {
console.log(`Using cached email list for ${userId}:${folder}:${page}:${perPage}`);
console.log(`Using cached email list for ${cacheKey}:${page}:${perPage}`);
return cachedResult;
}
}
console.log(`Cache miss for emails ${userId}:${folder}:${page}:${perPage}, fetching from IMAP`);
console.log(`Cache miss for emails ${userId}:${folder}:${page}:${perPage}${accountId ? ` for account ${accountId}` : ''}, fetching from IMAP`);
// If accountId is provided, connect to that specific account
let client: ImapFlow;
if (accountId) {
try {
// Get account from database
const account = await prisma.mailCredentials.findUnique({
where: {
id: accountId,
userId
}
});
if (!account) {
throw new Error(`Account with ID ${accountId} not found`);
}
// Connect to IMAP server for this specific account
client = new ImapFlow({
host: account.host,
port: account.port,
secure: true, // Default to secure connection
auth: {
user: account.email,
pass: account.password,
},
logger: false,
tls: {
rejectUnauthorized: false
}
});
await client.connect();
} catch (error) {
console.error(`Error connecting to account ${accountId}:`, error);
// Fallback to default connection
client = await getImapConnection(userId);
}
} else {
// Use the default connection logic
client = await getImapConnection(userId);
}
const client = await getImapConnection(userId);
let mailboxes: string[] = [];
try {

View File

@ -226,9 +226,10 @@ export async function prefetchFolderEmails(
userId: string,
folder: string,
pages: number = 3,
startPage: number = 1
startPage: number = 1,
accountId?: string
): Promise<void> {
const prefetchKey = `folder:${folder}:${startPage}`;
const prefetchKey = `folder:${folder}:${startPage}${accountId ? `:${accountId}` : ''}`;
// Skip if already in progress or in cooldown
if (!shouldPrefetch(userId, prefetchKey)) {
@ -236,7 +237,7 @@ export async function prefetchFolderEmails(
}
try {
console.log(`Prefetching ${pages} pages of emails for folder ${folder} starting from page ${startPage}`);
console.log(`Prefetching ${pages} pages of emails for folder ${folder} starting from page ${startPage}${accountId ? ` for account ${accountId}` : ''}`);
// Calculate the range of pages to prefetch
const pagesToFetch = Array.from(