courrier multi account restore compose

This commit is contained in:
alma 2025-04-28 20:37:05 +02:00
parent f05a982a3a
commit 9fa4f807eb
4 changed files with 211 additions and 68 deletions

View File

@ -1,10 +1,12 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import EmailPreview from './EmailPreview';
import ComposeEmail from './ComposeEmail';
import { Loader2 } from 'lucide-react';
import { formatReplyEmail, EmailMessage as FormatterEmailMessage } from '@/lib/utils/email-formatter';
import { useEmailFetch } from '@/hooks/use-email-fetch';
import { debounce } from '@/lib/utils/debounce';
// Add local EmailMessage interface
interface EmailAddress {
@ -40,28 +42,28 @@ interface EmailMessage {
}
interface EmailPanelProps {
selectedEmail: { emailId: string; accountId: string; folder: string } | null;
onSendEmail: (emailData: {
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
attachments?: Array<{
name: string;
content: string;
type: string;
}>;
}) => Promise<void>;
selectedEmail: {
emailId: string;
accountId: string;
folder: string;
} | null;
onSendEmail: (email: any) => void;
}
export default function EmailPanel({
selectedEmail,
onSendEmail
}: EmailPanelProps) {
const [email, setEmail] = useState<EmailMessage | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Use the new email fetch hook
const { email, loading, error, fetchEmail } = useEmailFetch({
onEmailLoaded: (email) => {
// Handle any post-load actions
console.log('Email loaded:', email.id);
},
onError: (error) => {
console.error('Error loading email:', error);
}
});
// Compose mode state
const [isComposing, setIsComposing] = useState<boolean>(false);
@ -111,57 +113,21 @@ export default function EmailPanel({
}
}, [email]);
// Debounced email fetch
const debouncedFetchEmail = useCallback(
debounce((emailId: string, accountId: string, folder: string) => {
fetchEmail(emailId, accountId, folder);
}, 300),
[fetchEmail]
);
// Load email content when selectedEmail changes
useEffect(() => {
if (selectedEmail) {
fetchEmail(selectedEmail.emailId, selectedEmail.accountId, selectedEmail.folder);
debouncedFetchEmail(selectedEmail.emailId, selectedEmail.accountId, selectedEmail.folder);
setIsComposing(false);
} else {
setEmail(null);
}
}, [selectedEmail]);
// Fetch the email content
const fetchEmail = async (emailId: string, accountId: string, folder: string) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/courrier/${emailId}?accountId=${encodeURIComponent(accountId)}&folder=${encodeURIComponent(folder)}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch email');
}
const data = await response.json();
if (!data) {
throw new Error('Email not found');
}
// Mark as read if not already
if (!data.flags?.seen) {
markAsRead(emailId);
}
setEmail(data);
} catch (err) {
console.error('Error fetching email:', err);
setError(err instanceof Error ? err.message : 'Failed to load email');
} finally {
setLoading(false);
}
};
// Mark email as read
const markAsRead = async (id: string) => {
try {
await fetch(`/api/courrier/${id}/mark-read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'mark-read' }),
});
} catch (err) {
console.error('Error marking email as read:', err);
}
};
}, [selectedEmail, debouncedFetchEmail]);
// Handle reply/forward actions
const handleReply = (type: 'reply' | 'reply-all' | 'forward') => {
@ -174,7 +140,7 @@ export default function EmailPanel({
setIsComposing(false);
setComposeType('new');
};
// If no email is selected and not composing
if (!selectedEmail && !isComposing) {
return (
@ -227,7 +193,6 @@ export default function EmailPanel({
// Show compose mode or email preview
return (
<div className="h-full">
{/* Remove ComposeEmail overlay/modal logic. Only show preview or a button to trigger compose in parent. */}
{isComposing ? (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">

126
hooks/use-email-fetch.ts Normal file
View File

@ -0,0 +1,126 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from './use-toast';
interface EmailFetchState {
email: any | null;
loading: boolean;
error: string | null;
}
interface UseEmailFetchProps {
onEmailLoaded?: (email: any) => void;
onError?: (error: string) => void;
}
export function useEmailFetch({ onEmailLoaded, onError }: UseEmailFetchProps = {}) {
const [state, setState] = useState<EmailFetchState>({
email: null,
loading: false,
error: null
});
const abortControllerRef = useRef<AbortController | null>(null);
const { toast } = useToast();
// Validate email fetch parameters
const validateFetchParams = (emailId: string, accountId: string, folder: string) => {
if (!emailId || typeof emailId !== 'string') {
throw new Error('Invalid email ID');
}
if (!accountId || typeof accountId !== 'string') {
throw new Error('Invalid account ID');
}
if (!folder || typeof folder !== 'string') {
throw new Error('Invalid folder');
}
// Validate UID format
if (!/^\d+$/.test(emailId)) {
throw new Error('Email ID must be a numeric UID');
}
};
// Fetch email with proper error handling and cancellation
const fetchEmail = useCallback(async (emailId: string, accountId: string, folder: string) => {
try {
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
// Validate parameters
validateFetchParams(emailId, accountId, folder);
setState(prev => ({ ...prev, loading: true, error: null }));
const response = await fetch(
`/api/courrier/${emailId}?accountId=${encodeURIComponent(accountId)}&folder=${encodeURIComponent(folder)}`,
{
signal: abortControllerRef.current.signal
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch email');
}
const data = await response.json();
if (!data) {
throw new Error('Email not found');
}
setState({ email: data, loading: false, error: null });
onEmailLoaded?.(data);
// Mark as read if not already
if (!data.flags?.seen) {
try {
await fetch(`/api/courrier/${emailId}/mark-read`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'mark-read' })
});
} catch (err) {
console.error('Error marking email as read:', err);
}
}
} catch (err) {
// Don't set error if request was aborted
if (err.name === 'AbortError') {
return;
}
const errorMessage = err instanceof Error ? err.message : 'Failed to load email';
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
onError?.(errorMessage);
// Show toast for user feedback
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive'
});
}
}, [onEmailLoaded, onError, toast]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
...state,
fetchEmail
};
}

View File

@ -400,6 +400,22 @@ export async function getEmailContent(
folder: string = 'INBOX',
accountId?: string
): Promise<EmailMessage> {
// Validate parameters
if (!userId || !emailId || !folder) {
throw new Error('Missing required parameters');
}
// Validate UID format
if (!/^\d+$/.test(emailId)) {
throw new Error('Invalid email ID format: must be a numeric UID');
}
// Convert to number for IMAP
const numericId = parseInt(emailId, 10);
if (isNaN(numericId)) {
throw new Error('Email ID must be a number');
}
// Try to get from cache first
const cachedEmail = await getCachedEmailContent(userId, accountId || folder, emailId);
if (cachedEmail) {
@ -414,9 +430,25 @@ export async function getEmailContent(
try {
// Remove accountId prefix if present
const actualFolder = folder.includes(':') ? folder.split(':')[1] : folder;
await client.mailboxOpen(actualFolder);
const message = await client.fetchOne(emailId, {
// Log connection details
console.log(`[DEBUG] Fetching email ${emailId} from folder ${actualFolder} for account ${accountId}`);
// Open mailbox with error handling
const mailbox = await client.mailboxOpen(actualFolder);
if (!mailbox || typeof mailbox === 'boolean') {
throw new Error(`Failed to open mailbox: ${actualFolder}`);
}
// Log mailbox status
console.log(`[DEBUG] Mailbox ${actualFolder} opened, total messages: ${mailbox.exists}`);
// Validate UID exists in mailbox
if (numericId > mailbox.uidNext) {
throw new Error(`Email ID ${numericId} is greater than the highest UID in mailbox (${mailbox.uidNext})`);
}
const message = await client.fetchOne(numericId.toString(), {
source: true,
envelope: true,
flags: true,
@ -424,7 +456,7 @@ export async function getEmailContent(
});
if (!message) {
throw new Error('Email not found');
throw new Error(`Email not found with ID ${numericId} in folder ${actualFolder}`);
}
const { source, envelope, flags, size } = message;
@ -488,6 +520,15 @@ export async function getEmailContent(
await cacheEmailContent(userId, accountId || folder, emailId, email);
return email;
} catch (error) {
console.error('[ERROR] Email fetch failed:', {
userId,
emailId,
folder,
accountId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw error;
} finally {
try {
await client.mailboxClose();

11
lib/utils/debounce.ts Normal file
View File

@ -0,0 +1,11 @@
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}