courrier multi account

This commit is contained in:
alma 2025-04-27 16:36:09 +02:00
parent b66f1adb3a
commit 9f9507b784
4 changed files with 299 additions and 47 deletions

View File

@ -0,0 +1,133 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { getRedisClient } from '@/lib/redis';
// This is an admin-only route to restore email credentials from Redis to the database
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Only allow authorized users
if (!session.user.role.includes('admin')) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
}
const redis = getRedisClient();
// Get all email credential keys
const keys = await redis.keys('email:credentials:*');
console.log(`Found ${keys.length} credential records in Redis`);
const results = {
total: keys.length,
processed: 0,
success: 0,
errors: [] as string[]
};
// Process each key
for (const key of keys) {
results.processed++;
try {
// Extract user ID from key
const userId = key.split(':')[2];
if (!userId) {
results.errors.push(`Invalid key format: ${key}`);
continue;
}
// Get credentials from Redis
const credStr = await redis.get(key);
if (!credStr) {
results.errors.push(`No data found for key: ${key}`);
continue;
}
// Parse credentials
const creds = JSON.parse(credStr);
console.log(`Processing credentials for user ${userId}`, {
email: creds.email,
host: creds.host
});
// Check if user exists
const userExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true }
});
if (!userExists) {
// Create dummy user if needed (this is optional and might not be appropriate in all cases)
// Remove or modify this section if you don't want to create placeholder users
console.log(`User ${userId} not found, creating placeholder`);
await prisma.user.create({
data: {
id: userId,
email: creds.email || 'placeholder@example.com',
password: 'PLACEHOLDER_HASH_CHANGE_THIS', // You should set a proper password
// Add any other required fields
}
});
console.log(`Created placeholder user ${userId}`);
}
// Upsert credentials in database
await prisma.mailCredentials.upsert({
where: { userId },
update: {
email: creds.email,
password: creds.encryptedPassword || 'encrypted_placeholder',
host: creds.host,
port: creds.port,
// Optional fields
...(creds.secure !== undefined && { secure: creds.secure }),
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
...(creds.display_name && { display_name: creds.display_name }),
...(creds.color && { color: creds.color })
},
create: {
userId,
email: creds.email,
password: creds.encryptedPassword || 'encrypted_placeholder',
host: creds.host,
port: creds.port,
// Optional fields
...(creds.secure !== undefined && { secure: creds.secure }),
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
...(creds.display_name && { display_name: creds.display_name }),
...(creds.color && { color: creds.color })
}
});
results.success++;
console.log(`Successfully restored credentials for user ${userId}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
results.errors.push(`Error processing ${key}: ${message}`);
console.error(`Error processing ${key}:`, error);
}
}
return NextResponse.json({
message: 'Credential restoration process completed',
results
});
} catch (error) {
console.error('Error in restore credentials route:', error);
return NextResponse.json(
{ error: 'Failed to restore credentials', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getRedisClient } from '@/lib/redis';
// This route just views Redis email credentials without making any changes
export async function GET(request: Request) {
try {
// Authenticate user
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const redis = getRedisClient();
// Get all email credential keys
const keys = await redis.keys('email:credentials:*');
console.log(`Found ${keys.length} credential records in Redis`);
const credentials = [];
// Process each key
for (const key of keys) {
try {
// Extract user ID from key
const userId = key.split(':')[2];
// Get credentials from Redis
const credStr = await redis.get(key);
if (!credStr) continue;
// Parse credentials
const creds = JSON.parse(credStr);
// Add to results (remove sensitive data)
credentials.push({
userId,
email: creds.email,
host: creds.host,
port: creds.port,
hasPassword: !!creds.encryptedPassword,
// Include other non-sensitive fields
smtp_host: creds.smtp_host,
smtp_port: creds.smtp_port,
display_name: creds.display_name,
color: creds.color
});
} catch (error) {
console.error(`Error processing ${key}:`, error);
}
}
return NextResponse.json({
count: credentials.length,
credentials
});
} catch (error) {
console.error('Error viewing Redis credentials:', error);
return NextResponse.json(
{ error: 'Failed to view credentials', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@ -1,7 +1,8 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { saveUserEmailCredentials } from '@/lib/services/email-service';
import { saveUserEmailCredentials, testEmailConnection } from '@/lib/services/email-service';
import { prisma } from '@/lib/prisma';
// Define EmailCredentials interface inline since we're having import issues
interface EmailCredentials {
@ -17,6 +18,22 @@ interface EmailCredentials {
color?: string;
}
/**
* Check if a user exists in the database
*/
async function userExists(userId: string): Promise<boolean> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true }
});
return !!user;
} catch (error) {
console.error(`Error checking if user exists:`, error);
return false;
}
}
export async function POST(request: Request) {
try {
// Authenticate user
@ -28,6 +45,19 @@ export async function POST(request: Request) {
);
}
// Verify that the user exists in the database
const userExistsInDB = await userExists(session.user.id);
if (!userExistsInDB) {
console.error(`User with ID ${session.user.id} not found in database`);
return NextResponse.json(
{
error: 'User not found in database',
details: `The user ID from your session (${session.user.id}) doesn't exist in the database. This may be due to a session/database mismatch.`
},
{ status: 400 }
);
}
// Parse request body
const body = await request.json().catch(e => {
console.error('Error parsing request body:', e);
@ -92,8 +122,19 @@ export async function POST(request: Request) {
...(color && { color })
};
// Connection test is no longer needed here since we validate with the test-connection endpoint first
// Test connection before saving
console.log(`Testing connection before saving for user ${session.user.id}`);
const testResult = await testEmailConnection(credentials);
if (!testResult.imap) {
return NextResponse.json(
{ error: `Connection test failed: ${testResult.error || 'Could not connect to IMAP server'}` },
{ status: 400 }
);
}
// Save credentials to database and cache
console.log(`Saving credentials for user: ${session.user.id}`);
await saveUserEmailCredentials(session.user.id, credentials);
console.log(`Email account successfully added for user ${session.user.id}`);

View File

@ -428,26 +428,28 @@ export default function CourrierPage() {
</Button>
</div>
{/* Accounts Section */}
<div className="p-3 border-b border-gray-100">
<div className="flex items-center justify-between mb-2">
<Button
variant="ghost"
className="w-full justify-between text-sm font-medium text-gray-500"
onClick={() => setAccountsDropdownOpen(!accountsDropdownOpen)}
>
<span>Accounts</span>
{accountsDropdownOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-gray-400 hover:text-gray-600"
onClick={() => setShowAddAccountForm(!showAddAccountForm)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Scrollable area for accounts and folders */}
<ScrollArea className="flex-1 h-0">
{/* Accounts Section */}
<div className="p-3 border-b border-gray-100">
<div className="flex items-center justify-between mb-2">
<Button
variant="ghost"
className="w-full justify-between text-sm font-medium text-gray-500"
onClick={() => setAccountsDropdownOpen(!accountsDropdownOpen)}
>
<span>Accounts</span>
{accountsDropdownOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-gray-400 hover:text-gray-600"
onClick={() => setShowAddAccountForm(!showAddAccountForm)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Form for adding a new account */}
{showAddAccountForm && (
@ -656,9 +658,11 @@ export default function CourrierPage() {
variant="ghost"
className="w-full justify-between px-2 py-1.5 text-sm group"
onClick={() => {
if (account.id !== 0) {
// Only toggle folders for non-All accounts
// Toggle folders for this specific account
if (selectedAccount?.id === account.id) {
setShowFolders(!showFolders);
} else {
setShowFolders(true);
}
setSelectedAccount(account);
}}
@ -667,18 +671,17 @@ export default function CourrierPage() {
<div className={`w-2.5 h-2.5 rounded-full ${account.color}`}></div>
<span className="font-medium text-gray-700 truncate">{account.name}</span>
</div>
{account.id !== 0 && (
<div className="flex items-center">
{showFolders ?
<ChevronDown className="h-3.5 w-3.5 text-gray-500" /> :
<ChevronRight className="h-3.5 w-3.5 text-gray-500" />
}
</div>
)}
{/* Show arrow for all accounts */}
<div className="flex items-center">
{selectedAccount?.id === account.id && showFolders ?
<ChevronDown className="h-3.5 w-3.5 text-gray-500" /> :
<ChevronRight className="h-3.5 w-3.5 text-gray-500" />
}
</div>
</Button>
{/* Show folders for email accounts (not for "All" account) without the "Folders" header */}
{account.id !== 0 && showFolders && (
{/* Show folders for this account if it's selected and folders are shown */}
{selectedAccount?.id === account.id && showFolders && account.folders && (
<div className="pl-4 mt-1 mb-2 space-y-0.5 border-l border-gray-200">
{account.folders && account.folders.length > 0 ? (
account.folders.map((folder) => (
@ -699,7 +702,7 @@ export default function CourrierPage() {
<span className="truncate">{formatFolderName(folder)}</span>
</div>
{folder === 'INBOX' && unreadCount > 0 && (
<span className="ml-auto bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full text-[10px]">
<span className="inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded">
{unreadCount}
</span>
)}
@ -707,17 +710,7 @@ export default function CourrierPage() {
</Button>
))
) : (
<div className="px-2 py-2">
<div className="flex flex-col space-y-2">
{/* Create placeholder folder items with shimmer effect */}
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex items-center gap-1.5 animate-pulse">
<div className="h-3.5 w-3.5 bg-gray-200 rounded-sm"></div>
<div className="h-3 w-24 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
<div className="text-xs text-gray-500 px-2 py-1">No folders found</div>
)}
</div>
)}
@ -725,7 +718,27 @@ export default function CourrierPage() {
))}
</div>
)}
</div>
</div>
{/* Categories Section */}
<div className="p-3 border-b border-gray-100">
<h4 className="mb-2 text-sm font-medium text-gray-500">Categories</h4>
<div className="space-y-1 pl-2">
<Button
variant="ghost"
className={`w-full justify-start text-sm py-1 px-2 ${
currentView === 'starred' ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => handleMailboxChange('starred')}
>
<div className="flex gap-2 items-center">
<Star className="h-3.5 w-3.5" />
<span>Starred</span>
</div>
</Button>
</div>
</div>
</ScrollArea>
</div>
{/* Email List and Content View */}