courrier multi account restore compose
This commit is contained in:
parent
f05a982a3a
commit
9fa4f807eb
@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import EmailPreview from './EmailPreview';
|
import EmailPreview from './EmailPreview';
|
||||||
import ComposeEmail from './ComposeEmail';
|
import ComposeEmail from './ComposeEmail';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { formatReplyEmail, EmailMessage as FormatterEmailMessage } from '@/lib/utils/email-formatter';
|
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
|
// Add local EmailMessage interface
|
||||||
interface EmailAddress {
|
interface EmailAddress {
|
||||||
@ -40,28 +42,28 @@ interface EmailMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EmailPanelProps {
|
interface EmailPanelProps {
|
||||||
selectedEmail: { emailId: string; accountId: string; folder: string } | null;
|
selectedEmail: {
|
||||||
onSendEmail: (emailData: {
|
emailId: string;
|
||||||
to: string;
|
accountId: string;
|
||||||
cc?: string;
|
folder: string;
|
||||||
bcc?: string;
|
} | null;
|
||||||
subject: string;
|
onSendEmail: (email: any) => void;
|
||||||
body: string;
|
|
||||||
attachments?: Array<{
|
|
||||||
name: string;
|
|
||||||
content: string;
|
|
||||||
type: string;
|
|
||||||
}>;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailPanel({
|
export default function EmailPanel({
|
||||||
selectedEmail,
|
selectedEmail,
|
||||||
onSendEmail
|
onSendEmail
|
||||||
}: EmailPanelProps) {
|
}: EmailPanelProps) {
|
||||||
const [email, setEmail] = useState<EmailMessage | null>(null);
|
// Use the new email fetch hook
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const { email, loading, error, fetchEmail } = useEmailFetch({
|
||||||
const [error, setError] = useState<string | null>(null);
|
onEmailLoaded: (email) => {
|
||||||
|
// Handle any post-load actions
|
||||||
|
console.log('Email loaded:', email.id);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error loading email:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Compose mode state
|
// Compose mode state
|
||||||
const [isComposing, setIsComposing] = useState<boolean>(false);
|
const [isComposing, setIsComposing] = useState<boolean>(false);
|
||||||
@ -111,57 +113,21 @@ export default function EmailPanel({
|
|||||||
}
|
}
|
||||||
}, [email]);
|
}, [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
|
// Load email content when selectedEmail changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEmail) {
|
if (selectedEmail) {
|
||||||
fetchEmail(selectedEmail.emailId, selectedEmail.accountId, selectedEmail.folder);
|
debouncedFetchEmail(selectedEmail.emailId, selectedEmail.accountId, selectedEmail.folder);
|
||||||
setIsComposing(false);
|
setIsComposing(false);
|
||||||
} else {
|
|
||||||
setEmail(null);
|
|
||||||
}
|
}
|
||||||
}, [selectedEmail]);
|
}, [selectedEmail, debouncedFetchEmail]);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle reply/forward actions
|
// Handle reply/forward actions
|
||||||
const handleReply = (type: 'reply' | 'reply-all' | 'forward') => {
|
const handleReply = (type: 'reply' | 'reply-all' | 'forward') => {
|
||||||
@ -227,7 +193,6 @@ export default function EmailPanel({
|
|||||||
// Show compose mode or email preview
|
// Show compose mode or email preview
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
{/* Remove ComposeEmail overlay/modal logic. Only show preview or a button to trigger compose in parent. */}
|
|
||||||
{isComposing ? (
|
{isComposing ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
|
|||||||
126
hooks/use-email-fetch.ts
Normal file
126
hooks/use-email-fetch.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -400,6 +400,22 @@ export async function getEmailContent(
|
|||||||
folder: string = 'INBOX',
|
folder: string = 'INBOX',
|
||||||
accountId?: string
|
accountId?: string
|
||||||
): Promise<EmailMessage> {
|
): 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
|
// Try to get from cache first
|
||||||
const cachedEmail = await getCachedEmailContent(userId, accountId || folder, emailId);
|
const cachedEmail = await getCachedEmailContent(userId, accountId || folder, emailId);
|
||||||
if (cachedEmail) {
|
if (cachedEmail) {
|
||||||
@ -414,9 +430,25 @@ export async function getEmailContent(
|
|||||||
try {
|
try {
|
||||||
// Remove accountId prefix if present
|
// Remove accountId prefix if present
|
||||||
const actualFolder = folder.includes(':') ? folder.split(':')[1] : folder;
|
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,
|
source: true,
|
||||||
envelope: true,
|
envelope: true,
|
||||||
flags: true,
|
flags: true,
|
||||||
@ -424,7 +456,7 @@ export async function getEmailContent(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!message) {
|
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;
|
const { source, envelope, flags, size } = message;
|
||||||
@ -488,6 +520,15 @@ export async function getEmailContent(
|
|||||||
await cacheEmailContent(userId, accountId || folder, emailId, email);
|
await cacheEmailContent(userId, accountId || folder, emailId, email);
|
||||||
|
|
||||||
return 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 {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await client.mailboxClose();
|
await client.mailboxClose();
|
||||||
|
|||||||
11
lib/utils/debounce.ts
Normal file
11
lib/utils/debounce.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user