panel 2 courier api restore

This commit is contained in:
alma 2025-04-26 11:27:01 +02:00
parent 6edcb636c8
commit b58539aeaa
9 changed files with 290 additions and 796 deletions

View File

@ -1,22 +0,0 @@
import { NextResponse } from 'next/server';
/**
* This route is deprecated. It redirects to the new courrier API endpoint.
* @deprecated Use the /api/courrier endpoint instead
*/
export async function GET(request: Request) {
console.warn('Deprecated: /api/mail route is being used. Update your code to use /api/courrier instead.');
// Extract query parameters
const url = new URL(request.url);
// Redirect to the new API endpoint
const redirectUrl = new URL('/api/courrier', url.origin);
// Copy all search parameters
url.searchParams.forEach((value, key) => {
redirectUrl.searchParams.set(key, value);
});
return NextResponse.redirect(redirectUrl.toString());
}

View File

@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
/**
* 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) {
console.warn('Deprecated: /api/mail/send route is being used. Update your code to use /api/courrier/send instead.');
try {
// Clone the request body
const body = await request.json();
// Make a new request to the courrier API
const newRequest = new Request(new URL('/api/courrier/send', request.url).toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
// Forward the request
const response = await fetch(newRequest);
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Error forwarding to courrier/send:', error);
return NextResponse.json(
{ error: 'Failed to send email' },
{ status: 500 }
);
}
}

View File

@ -1,38 +1,83 @@
import { NextResponse } from 'next/server';
import { simpleParser, AddressObject } from 'mailparser';
import { NextRequest, NextResponse } from 'next/server';
import { simpleParser } from 'mailparser';
import * as DOMPurify from 'isomorphic-dompurify';
function getEmailAddress(address: AddressObject | AddressObject[] | undefined): string | null {
if (!address) return null;
if (Array.isArray(address)) {
return address.map(a => a.text).join(', ');
}
return address.text;
interface EmailAddress {
name?: string;
address: string;
}
// Clean up the HTML to make it safe but preserve styles
function processHtml(html: string | null): string | null {
if (!html) return null;
// Helper to extract email addresses from mailparser Address objects
function getEmailAddresses(addresses: any): EmailAddress[] {
if (!addresses) return [];
// Handle various address formats
if (Array.isArray(addresses)) {
return addresses.map(addr => ({
name: addr.name || undefined,
address: addr.address
}));
}
if (typeof addresses === 'object') {
const result: EmailAddress[] = [];
// Handle mailparser format with text, html, value properties
if (addresses.value) {
addresses.value.forEach((addr: any) => {
result.push({
name: addr.name || undefined,
address: addr.address
});
});
return result;
}
// Handle direct object with address property
if (addresses.address) {
return [{
name: addresses.name || undefined,
address: addresses.address
}];
}
}
return [];
}
// Process HTML to ensure it displays well in our email context
function processHtml(html: string): string {
if (!html) return '';
try {
// Make the content display well in the email context
return html
// Fix self-closing tags that might break React
.replace(/<(br|hr|img|input|link|meta|area|base|col|embed|keygen|param|source|track|wbr)([^>]*)>/gi, '<$1$2 />')
// Keep style tags but ensure they're closed properly
.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (match) => {
// Just return the matched style tag as-is
return match;
});
} catch (error) {
console.error('Error processing HTML:', error);
return html;
// Fix self-closing tags that might break in contentEditable
html = html.replace(/<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)([^>]*)>/gi,
(match, tag, attrs) => `<${tag}${attrs}${attrs.endsWith('/') ? '' : '/'}>`)
// Clean up HTML with DOMPurify - CRITICAL for security
// Allow style tags but remove script tags
const cleaned = DOMPurify.sanitize(html, {
ADD_TAGS: ['style'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
WHOLE_DOCUMENT: false
});
// Scope CSS to prevent leakage
return cleaned.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (match, attrs, css) => {
// Generate a unique class for this email content
const uniqueClass = `email-content-${Date.now()}`;
// Add the unique class to outer container that will be added
return `<style${attrs}>.${uniqueClass} {contain: content;} .${uniqueClass} ${css}</style>`;
});
} catch (e) {
console.error('Error processing HTML:', e);
return html; // Return original if processing fails
}
}
export async function POST(request: Request) {
export async function POST(req: NextRequest) {
try {
const body = await request.json();
const { email } = body;
const { email } = await req.json();
if (!email || typeof email !== 'string') {
return NextResponse.json(
@ -40,33 +85,42 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
const parsed = await simpleParser(email);
// Process the HTML to preserve styling but make it safe
// Handle the case where parsed.html could be a boolean
const processedHtml = typeof parsed.html === 'string' ? processHtml(parsed.html) : null;
// Process the HTML content to make it safe and displayable
const html = parsed.html
? processHtml(parsed.html.toString())
: undefined;
const text = parsed.text
? parsed.text.toString()
: undefined;
// Extract attachments info if available
const attachments = parsed.attachments?.map(attachment => ({
filename: attachment.filename,
contentType: attachment.contentType,
contentDisposition: attachment.contentDisposition,
size: attachment.size
})) || [];
// Return all parsed email details
return NextResponse.json({
subject: parsed.subject || null,
from: getEmailAddress(parsed.from),
to: getEmailAddress(parsed.to),
cc: getEmailAddress(parsed.cc),
bcc: getEmailAddress(parsed.bcc),
date: parsed.date || null,
html: processedHtml,
text: parsed.textAsHtml || parsed.text || null,
attachments: parsed.attachments?.map(att => ({
filename: att.filename,
contentType: att.contentType,
size: att.size
})) || [],
headers: parsed.headers || {}
subject: parsed.subject,
from: getEmailAddresses(parsed.from),
to: getEmailAddresses(parsed.to),
cc: getEmailAddresses(parsed.cc),
bcc: getEmailAddresses(parsed.bcc),
date: parsed.date,
html,
text,
attachments
});
} catch (error) {
console.error('Error parsing email:', error);
return NextResponse.json(
{ error: 'Failed to parse email' },
{ error: 'Failed to parse email content' },
{ status: 500 }
);
}

View File

@ -1,6 +1,9 @@
/**
* This is a debugging component that provides troubleshooting tools
* for the email loading process in the Courrier application.
*
* NOTE: This component should only be used during development for debugging purposes.
* It's kept in the codebase for future reference but won't render in production.
*/
'use client';
@ -26,6 +29,11 @@ export function LoadingFix({
loadEmails,
emails
}: LoadingFixProps) {
// Don't render anything in production mode
if (process.env.NODE_ENV === 'production') {
return null;
}
const forceResetLoadingStates = () => {
console.log('[DEBUG] Force resetting loading states to false');
// Force both loading states to false

View File

@ -95,6 +95,10 @@ interface ParsedEmailMetadata {
};
}
/**
* @deprecated This function is deprecated and will be removed in future versions.
* Email parsing has been centralized in lib/mail-parser-wrapper.ts and the API endpoint.
*/
function splitEmailHeadersAndBody(emailBody: string): { headers: string; body: string } {
const [headers, ...bodyParts] = emailBody.split('\r\n\r\n');
return {
@ -322,6 +326,15 @@ function formatDate(date: Date | null): string {
}).format(date);
}
/**
* @deprecated This function is deprecated and will be removed in future versions.
* Use the ReplyContent component directly instead.
*/
function getReplyBody(email: Email, type: 'reply' | 'reply-all' | 'forward' = 'reply') {
console.warn('getReplyBody is deprecated, use <ReplyContent email={email} type={type} /> instead');
return <ReplyContent email={email} type={type} />;
}
function ReplyContent({ email, type }: { email: Email; type: 'reply' | 'reply-all' | 'forward' }) {
const [content, setContent] = useState<string>('');
const [error, setError] = useState<string | null>(null);
@ -390,11 +403,6 @@ function ReplyContent({ email, type }: { email: Email; type: 'reply' | 'reply-al
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// Update the getReplyBody function to use the new component
function getReplyBody(email: Email, type: 'reply' | 'reply-all' | 'forward' = 'reply') {
return <ReplyContent email={email} type={type} />;
}
function EmailPreview({ email }: { email: Email }) {
const [preview, setPreview] = useState<string>('');
const [error, setError] = useState<string | null>(null);
@ -472,6 +480,9 @@ function EmailPreview({ email }: { email: Email }) {
// Update the generateEmailPreview function to use the new component
function generateEmailPreview(email: Email) {
// @deprecated - This function is deprecated and will be removed in future versions.
// Use the EmailPreview component directly instead.
console.warn('generateEmailPreview is deprecated, use <EmailPreview email={email} /> instead');
return <EmailPreview email={email} />;
}

View File

@ -1,587 +0,0 @@
'use client';
import { useRef, useEffect, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Paperclip, X } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { decodeComposeContent, encodeComposeContent } from '@/lib/compose-mime-decoder';
import { Email } from '@/app/courrier/page';
import mime from 'mime';
import { simpleParser } from 'mailparser';
import { decodeEmail } from '@/lib/mail-parser-wrapper';
import DOMPurify from 'dompurify';
interface ComposeEmailProps {
showCompose: boolean;
setShowCompose: (show: boolean) => void;
composeTo: string;
setComposeTo: (to: string) => void;
composeCc: string;
setComposeCc: (cc: string) => void;
composeBcc: string;
setComposeBcc: (bcc: string) => void;
composeSubject: string;
setComposeSubject: (subject: string) => void;
composeBody: string;
setComposeBody: (body: string) => void;
showCc: boolean;
setShowCc: (show: boolean) => void;
showBcc: boolean;
setShowBcc: (show: boolean) => void;
attachments: any[];
setAttachments: (attachments: any[]) => void;
handleSend: () => Promise<void>;
originalEmail?: {
content: string;
type: 'reply' | 'reply-all' | 'forward';
};
onSend: (email: Email) => void;
onCancel: () => void;
onBodyChange?: (body: string) => void;
initialTo?: string;
initialSubject?: string;
initialBody?: string;
initialCc?: string;
initialBcc?: string;
replyTo?: Email | null;
forwardFrom?: Email | null;
}
export default function ComposeEmail({
showCompose,
setShowCompose,
composeTo,
setComposeTo,
composeCc,
setComposeCc,
composeBcc,
setComposeBcc,
composeSubject,
setComposeSubject,
composeBody,
setComposeBody,
showCc,
setShowCc,
showBcc,
setShowBcc,
attachments,
setAttachments,
handleSend,
originalEmail,
onSend,
onCancel,
onBodyChange,
initialTo,
initialSubject,
initialBody,
initialCc,
initialBcc,
replyTo,
forwardFrom
}: ComposeEmailProps) {
const composeBodyRef = useRef<HTMLDivElement>(null);
const [localContent, setLocalContent] = useState('');
const [isLoading, setIsLoading] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (replyTo || forwardFrom) {
const initializeContent = async () => {
if (!composeBodyRef.current) return;
try {
const emailToProcess = replyTo || forwardFrom;
console.log('[DEBUG] Initializing compose content with email:',
emailToProcess ? {
id: emailToProcess.id,
subject: emailToProcess.subject,
hasContent: !!emailToProcess.content,
contentLength: emailToProcess.content ? emailToProcess.content.length : 0,
preview: emailToProcess.preview
} : 'null'
);
// Set initial loading state
composeBodyRef.current.innerHTML = `
<div class="compose-area" contenteditable="true">
<br/>
<div class="text-gray-500">Loading original message...</div>
</div>
`;
setIsLoading(true);
// Check if email object exists
if (!emailToProcess) {
console.error('[DEBUG] No email to process for reply/forward');
composeBodyRef.current.innerHTML = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #ef4444;">No email selected for reply/forward.</div>
</div>
`;
setIsLoading(false);
return;
}
// Check if we need to fetch full content first
if (!emailToProcess.content || emailToProcess.content.length === 0) {
console.log('[DEBUG] Need to fetch content before composing reply/forward');
try {
const response = await fetch(`/api/courrier/${emailToProcess.id}?folder=${encodeURIComponent(emailToProcess.folder || 'INBOX')}`);
if (!response.ok) {
throw new Error(`Failed to fetch email content: ${response.status}`);
}
const fullContent = await response.json();
// Update the email content with the fetched full content
emailToProcess.content = fullContent.content;
emailToProcess.contentFetched = true;
console.log('[DEBUG] Successfully fetched content for reply/forward');
} catch (error) {
console.error('[DEBUG] Error fetching content for reply:', error);
composeBodyRef.current.innerHTML = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #ef4444;">Failed to load email content. Please try again.</div>
</div>
`;
setIsLoading(false);
return; // Exit if we couldn't get the content
}
}
// Use the exact same implementation as Panel 3's ReplyContent
try {
const decoded = await decodeEmail(emailToProcess.content);
let formattedContent = '';
if (forwardFrom) {
// Create a clean header for the forwarded email
const headerHtml = `
<div style="border-bottom: 1px solid #e2e2e2; margin-bottom: 15px; padding-bottom: 15px; font-family: Arial, sans-serif;">
<p style="margin: 4px 0;">---------- Forwarded message ---------</p>
<p style="margin: 4px 0;"><b>From:</b> ${decoded.from || ''}</p>
<p style="margin: 4px 0;"><b>Date:</b> ${formatDate(decoded.date)}</p>
<p style="margin: 4px 0;"><b>Subject:</b> ${decoded.subject || ''}</p>
<p style="margin: 4px 0;"><b>To:</b> ${decoded.to || ''}</p>
</div>
`;
// Use the original HTML as-is without DOMPurify or any modification
formattedContent = `
${headerHtml}
${decoded.html || decoded.text || 'No content available'}
`;
} else {
formattedContent = `
<div class="quoted-message">
<p>On ${formatDate(decoded.date)}, ${decoded.from || ''} wrote:</p>
<blockquote>
<div class="email-content prose prose-sm max-w-none dark:prose-invert">
${decoded.html || `<pre>${decoded.text || ''}</pre>`}
</div>
</blockquote>
</div>
`;
}
// Set the content in the compose area with proper structure
const wrappedContent = `
<div class="compose-area" contenteditable="true" style="min-height: 100px; padding: 10px;">
<div style="min-height: 20px;"><br/></div>
${formattedContent}
</div>
`;
if (composeBodyRef.current) {
composeBodyRef.current.innerHTML = wrappedContent;
// Place cursor at the beginning before the quoted content
const selection = window.getSelection();
const range = document.createRange();
const firstDiv = composeBodyRef.current.querySelector('div[style*="min-height: 20px;"]');
if (firstDiv) {
range.setStart(firstDiv, 0);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
(firstDiv as HTMLElement).focus();
}
// Update compose state
setComposeBody(wrappedContent);
setLocalContent(wrappedContent);
console.log('[DEBUG] Successfully set compose content');
}
} catch (error) {
console.error('[DEBUG] Error parsing email for compose:', error);
// Fallback to basic content display
const errorContent = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #64748b;">
---------- Original Message ---------<br/>
${emailToProcess.subject ? `Subject: ${emailToProcess.subject}<br/>` : ''}
${emailToProcess.from ? `From: ${emailToProcess.from}<br/>` : ''}
${emailToProcess.date ? `Date: ${new Date(emailToProcess.date).toLocaleString()}<br/>` : ''}
</div>
<div style="color: #64748b; border-left: 2px solid #e5e7eb; padding-left: 10px; margin: 10px 0;">
${emailToProcess.preview || 'No content available'}
</div>
</div>
`;
if (composeBodyRef.current) {
composeBodyRef.current.innerHTML = errorContent;
setComposeBody(errorContent);
setLocalContent(errorContent);
}
}
} catch (error) {
console.error('[DEBUG] Error initializing compose content:', error);
if (composeBodyRef.current) {
const errorContent = `
<div class="compose-area" contenteditable="true">
<br/>
<div style="color: #ef4444;">Error loading original message.</div>
<div style="color: #64748b; font-size: 0.875rem; margin-top: 0.5rem;">
Technical details: ${error instanceof Error ? error.message : 'Unknown error'}
</div>
</div>
`;
composeBodyRef.current.innerHTML = errorContent;
setComposeBody(errorContent);
setLocalContent(errorContent);
}
} finally {
setIsLoading(false);
}
};
initializeContent();
}
}, [replyTo, forwardFrom, setComposeBody]);
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
if (!e.currentTarget) return;
const content = e.currentTarget.innerHTML;
if (!content.trim()) {
setLocalContent('');
setComposeBody('');
} else {
setLocalContent(content);
setComposeBody(content);
}
if (onBodyChange) {
onBodyChange(content);
}
// Ensure scrolling and cursor behavior works after edits
const messageContentDivs = e.currentTarget.querySelectorAll('.message-content');
messageContentDivs.forEach(div => {
// Make sure the div remains scrollable after input events
(div as HTMLElement).style.maxHeight = '300px';
(div as HTMLElement).style.overflowY = 'auto';
(div as HTMLElement).style.border = '1px solid #e5e7eb';
(div as HTMLElement).style.borderRadius = '4px';
(div as HTMLElement).style.padding = '10px';
// Ensure wheel events are properly handled
if (!(div as HTMLElement).hasAttribute('data-scroll-handler-attached')) {
div.addEventListener('wheel', function(this: HTMLElement, ev: Event) {
const e = ev as WheelEvent;
const target = this;
// Check if we're at the boundary of the scrollable area
const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 1;
const isAtTop = target.scrollTop <= 0;
// Only prevent default if we're not at the boundaries in the direction of scrolling
if ((e.deltaY > 0 && !isAtBottom) || (e.deltaY < 0 && !isAtTop)) {
e.stopPropagation();
e.preventDefault(); // Prevent the parent container from scrolling
}
}, { passive: false });
// Mark this element as having a scroll handler attached
(div as HTMLElement).setAttribute('data-scroll-handler-attached', 'true');
}
});
};
const handleSendEmail = async () => {
if (!composeBodyRef.current) return;
const composeArea = composeBodyRef.current.querySelector('.compose-area');
if (!composeArea) return;
const content = composeArea.innerHTML;
if (!content.trim()) {
console.error('Email content is empty');
return;
}
try {
const encodedContent = await encodeComposeContent(content);
setComposeBody(encodedContent);
await handleSend();
setShowCompose(false);
} catch (error) {
console.error('Error sending email:', error);
}
};
const handleFileAttachment = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const newAttachments: any[] = [];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
const oversizedFiles: string[] = [];
for (const file of e.target.files) {
if (file.size > MAX_FILE_SIZE) {
oversizedFiles.push(file.name);
continue;
}
try {
// Read file as base64
const base64Content = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
resolve(base64.split(',')[1]); // Remove data URL prefix
};
reader.readAsDataURL(file);
});
newAttachments.push({
name: file.name,
type: file.type,
content: base64Content,
encoding: 'base64'
});
} catch (error) {
console.error('Error processing attachment:', error);
}
}
if (oversizedFiles.length > 0) {
alert(`The following files exceed the 10MB size limit and were not attached:\n${oversizedFiles.join('\n')}`);
}
if (newAttachments.length > 0) {
setAttachments([...attachments, ...newAttachments]);
}
};
// Add focus handling for better UX
const handleComposeAreaClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If the click is directly on the compose area and not on any child element
if (e.target === e.currentTarget) {
// Find the cursor position element
const cursorPosition = e.currentTarget.querySelector('.cursor-position');
if (cursorPosition) {
// Focus the cursor position element
(cursorPosition as HTMLElement).focus();
// Set cursor at the beginning
const selection = window.getSelection();
const range = document.createRange();
range.setStart(cursorPosition, 0);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
};
// Add formatDate function to match Panel 3 implementation
function formatDate(date: Date | null): string {
if (!date) return '';
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
if (!showCompose) return null;
return (
<div className="fixed inset-0 bg-gray-600/30 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="w-full max-w-2xl h-[90vh] bg-white rounded-xl shadow-xl flex flex-col mx-4">
{/* Modal Header */}
<div className="flex-none flex items-center justify-between px-6 py-3 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
{replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'}
</h3>
<Button
variant="ghost"
size="icon"
className="hover:bg-gray-100 rounded-full"
onClick={onCancel}
>
<X className="h-5 w-5 text-gray-500" />
</Button>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-hidden">
<div className="h-full flex flex-col p-6 space-y-4 overflow-y-auto">
{/* To Field */}
<div className="flex-none">
<Label htmlFor="to" className="block text-sm font-medium text-gray-700">To</Label>
<Input
id="to"
value={composeTo}
onChange={(e) => setComposeTo(e.target.value)}
placeholder="recipient@example.com"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
{/* CC/BCC Toggle Buttons */}
<div className="flex-none flex items-center gap-4">
<button
type="button"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
onClick={() => setShowCc(!showCc)}
>
{showCc ? 'Hide Cc' : 'Add Cc'}
</button>
<button
type="button"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
onClick={() => setShowBcc(!showBcc)}
>
{showBcc ? 'Hide Bcc' : 'Add Bcc'}
</button>
</div>
{/* CC Field */}
{showCc && (
<div className="flex-none">
<Label htmlFor="cc" className="block text-sm font-medium text-gray-700">Cc</Label>
<Input
id="cc"
value={composeCc}
onChange={(e) => setComposeCc(e.target.value)}
placeholder="cc@example.com"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
)}
{/* BCC Field */}
{showBcc && (
<div className="flex-none">
<Label htmlFor="bcc" className="block text-sm font-medium text-gray-700">Bcc</Label>
<Input
id="bcc"
value={composeBcc}
onChange={(e) => setComposeBcc(e.target.value)}
placeholder="bcc@example.com"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
)}
{/* Subject Field */}
<div className="flex-none">
<Label htmlFor="subject" className="block text-sm font-medium text-gray-700">Subject</Label>
<Input
id="subject"
value={composeSubject}
onChange={(e) => setComposeSubject(e.target.value)}
placeholder="Enter subject"
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
/>
</div>
{/* Message Body */}
<div className="flex-1 min-h-[200px] flex flex-col">
<Label htmlFor="message" className="flex-none block text-sm font-medium text-gray-700 mb-2">Message</Label>
<div
ref={composeBodyRef}
contentEditable="true"
onInput={handleInput}
onClick={handleComposeAreaClick}
className="flex-1 w-full bg-white border border-gray-300 rounded-md p-4 text-black overflow-y-auto focus:outline-none focus:ring-1 focus:ring-blue-500"
style={{
direction: 'ltr',
maxHeight: 'calc(100vh - 400px)',
minHeight: '200px',
overflowY: 'auto',
scrollbarWidth: 'thin',
scrollbarColor: '#cbd5e0 #f3f4f6',
cursor: 'text'
}}
dir="ltr"
spellCheck="true"
role="textbox"
aria-multiline="true"
tabIndex={0}
suppressContentEditableWarning={true}
/>
</div>
</div>
</div>
{/* Modal Footer */}
<div className="flex-none flex items-center justify-between px-6 py-3 border-t border-gray-200 bg-white">
<div className="flex items-center gap-2">
{/* File Input for Attachments */}
<input
type="file"
id="file-attachment"
className="hidden"
multiple
onChange={handleFileAttachment}
/>
<label htmlFor="file-attachment">
<Button
variant="outline"
size="icon"
className="rounded-full bg-white hover:bg-gray-100 border-gray-300"
onClick={(e) => {
e.preventDefault();
document.getElementById('file-attachment')?.click();
}}
>
<Paperclip className="h-4 w-4 text-gray-600" />
</Button>
</label>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
className="text-gray-600 hover:text-gray-700 hover:bg-gray-100"
onClick={onCancel}
>
Cancel
</Button>
<Button
className="bg-blue-600 text-white hover:bg-blue-700"
onClick={handleSendEmail}
>
Send
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -179,8 +179,7 @@ export default function ComposeEmail({
: initialEmail.date.toLocaleString()
: new Date().toLocaleString();
// Create a clean wrapper that won't interfere with the original email's styling
// Use inline styles for the header to avoid CSS conflicts
// Create a clean header with inline styles only - no external CSS
const headerHtml = `
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 15px; font-family: Arial, sans-serif; color: #333;">
<div style="margin-bottom: 15px;">
@ -193,91 +192,83 @@ export default function ComposeEmail({
</div>
`;
// Process the original content
let originalContent = '';
// Default content is a clear "no content" message
let contentHtml = '<div style="color: #666; font-style: italic; padding: 15px; font-size: 14px; border: 1px dashed #ccc; margin: 15px 0; text-align: center; background-color: #f9f9f9; border-radius: 4px;">No content available in original email</div>';
// First try to use the API to parse and sanitize the email content
try {
// Use server-side parsing via fetch API to properly handle complex emails
const response = await fetch('/api/parse-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: initialEmail.content || initialEmail.html || initialEmail.text || ''
}),
});
if (response.ok) {
const parsedEmail = await response.json();
if (parsedEmail.html && parsedEmail.html.trim()) {
console.log('Using parsed HTML content for forward');
// Create an iframe-like containment for the email content
// This prevents CSS from the original email leaking into our compose view
originalContent = `
<div class="email-content-container">
${parsedEmail.html}
</div>
`;
} else if (parsedEmail.text && parsedEmail.text.trim()) {
console.log('Using parsed text content for forward');
originalContent = `<div style="white-space: pre-wrap; font-family: monospace;">${parsedEmail.text}</div>`;
} else {
console.log('No content available from parser');
originalContent = '<div style="color: #666; font-style: italic; padding: 10px; font-size: 14px; border: 1px dashed #ccc; margin: 10px 0; text-align: center; background-color: #f9f9f9;">No content available</div>';
}
} else {
throw new Error('Failed to parse email content');
}
} catch (parseError) {
console.error('Error parsing email content:', parseError);
// Fall back to direct content handling if API parsing fails
if (initialEmail.html && initialEmail.html.trim()) {
console.log('Falling back to HTML content for forward');
// Use DOMPurify to sanitize HTML and remove dangerous elements
originalContent = DOMPurify.sanitize(initialEmail.html, {
ADD_TAGS: ['style', 'div', 'span', 'p', 'br', 'hr', 'h1', 'h2', 'h3', 'img', 'table', 'tr', 'td', 'th'],
ADD_ATTR: ['style', 'class', 'id', 'src', 'alt', 'href', 'target'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
// Check if we have content to forward
if (initialEmail.content || initialEmail.html || initialEmail.text) {
try {
// Use the parse-email API endpoint which centralizes our email parsing logic
const response = await fetch('/api/parse-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: initialEmail.content || initialEmail.html || initialEmail.text || ''
}),
});
} else if (initialEmail.content && initialEmail.content.trim()) {
console.log('Falling back to content field for forward');
originalContent = DOMPurify.sanitize(initialEmail.content);
} else if (initialEmail.text && initialEmail.text.trim()) {
console.log('Falling back to text content for forward');
originalContent = `<div style="white-space: pre-wrap; font-family: monospace;">${initialEmail.text}</div>`;
} else {
console.log('No content available for forward');
originalContent = '<div style="color: #666; font-style: italic; padding: 10px; font-size: 14px; border: 1px dashed #ccc; margin: 10px 0; text-align: center; background-color: #f9f9f9;">No content available</div>';
if (response.ok) {
const parsedEmail = await response.json();
// Use the parsed HTML content if available
if (parsedEmail.html) {
contentHtml = parsedEmail.html;
console.log('Successfully parsed HTML content');
} else if (parsedEmail.text) {
// Text-only content is wrapped in pre-formatted styling
contentHtml = `<div style="white-space: pre-wrap; font-family: monospace; padding: 10px; background-color: #f9f9f9; border-radius: 4px;">${parsedEmail.text}</div>`;
console.log('Using text content');
} else {
console.warn('API returned success but no content');
}
} else {
console.error('API returned error:', await response.text());
throw new Error('API call failed');
}
} catch (error) {
console.error('Error parsing email:', error);
// Fallback processing - using our cleanHtml utility directly
if (initialEmail.html) {
// Import the cleanHtml function dynamically if needed
const { cleanHtml } = await import('@/lib/mail-parser-wrapper');
contentHtml = cleanHtml(initialEmail.html, {
preserveStyles: true,
scopeStyles: true,
addWrapper: true
});
console.log('Using direct HTML cleaning fallback');
} else if (initialEmail.content) {
contentHtml = DOMPurify.sanitize(initialEmail.content, {
ADD_TAGS: ['style'],
FORBID_TAGS: ['script', 'iframe']
});
console.log('Using DOMPurify sanitized content');
} else if (initialEmail.text) {
contentHtml = `<div style="white-space: pre-wrap; font-family: monospace; padding: 10px; background-color: #f9f9f9; border-radius: 4px;">${initialEmail.text}</div>`;
console.log('Using plain text fallback');
}
}
} else {
console.warn('No email content available for forwarding');
}
// Preserve all original structure by wrapping, not modifying the original content
// Important: We add a style scope to prevent CSS leakage
// Combine the header and content - using containment
const forwardedContent = `
${headerHtml}
<!-- Start original email content - DO NOT MODIFY THIS CONTENT -->
<div class="original-email-content" style="margin-top: 10px; border-left: 2px solid #e1e1e1; padding-left: 15px;">
<!-- Email content styling isolation container -->
<div style="position: relative; overflow: auto;">
${originalContent}
<div style="margin-top: 10px; border-left: 2px solid #e1e1e1; padding-left: 15px;">
<div style="isolation: isolate; contain: content; overflow: auto;">
${contentHtml}
</div>
</div>
<!-- End original email content -->
`;
console.log('Setting body with forwarded content');
setBody(forwardedContent);
} catch (error) {
console.error('Error initializing forwarded email:', error);
// Even in error case, provide a usable template with empty values
// Provide a minimal template even in error case
setBody(`
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 15px; font-family: Arial, sans-serif; color: #333;">
<div style="margin-bottom: 15px;">
@ -288,7 +279,7 @@ export default function ComposeEmail({
<div><b>To:</b> ${initialEmail.to ? formatRecipients(initialEmail.to) : ''}</div>
</div>
</div>
<div style="margin-top: 10px; padding: 10px; color: #d32f2f; font-style: italic; border: 1px dashed #d32f2f; margin: 10px 0; text-align: center; background-color: #fff8f8;">
<div style="margin-top: 10px; padding: 15px; color: #d32f2f; font-style: italic; border: 1px dashed #d32f2f; margin: 15px 0; text-align: center; background-color: #fff8f8; border-radius: 4px;">
Error loading original message content. The original message may still be viewable in your inbox.
</div>
`);

View File

@ -101,23 +101,107 @@ export async function decodeEmail(emailContent: string): Promise<ParsedEmail> {
}
}
export function cleanHtml(html: string): string {
/**
* Cleans HTML content by removing potentially harmful elements while preserving styling.
* This is the centralized HTML sanitization function to be used across the application.
* @param html HTML content to sanitize
* @param options Optional configuration
* @returns Sanitized HTML
*/
export function cleanHtml(html: string, options: {
preserveStyles?: boolean;
scopeStyles?: boolean;
addWrapper?: boolean;
} = {}): string {
if (!html) return '';
try {
// Enhanced configuration to preserve more HTML elements for complex emails
return DOMPurify.sanitize(html, {
ADD_TAGS: ['style', 'meta', 'link', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'hr', 'font', 'div', 'span', 'a', 'img', 'b', 'strong', 'i', 'em', 'u', 'br', 'p', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'center', 'section', 'header', 'footer', 'article', 'nav', 'keyframes'],
ADD_ATTR: ['*', 'colspan', 'rowspan', 'cellpadding', 'cellspacing', 'border', 'bgcolor', 'width', 'height', 'align', 'valign', 'class', 'id', 'style', 'color', 'face', 'size', 'background', 'src', 'href', 'target', 'rel', 'alt', 'title', 'name', 'animation', 'animation-name', 'animation-duration', 'animation-fill-mode'],
ALLOW_UNKNOWN_PROTOCOLS: true,
WHOLE_DOCUMENT: true,
KEEP_CONTENT: true,
RETURN_DOM: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'option', 'textarea', 'canvas', 'video', 'audio'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onchange', 'onsubmit'],
USE_PROFILES: { html: true, svg: false, svgFilters: false, mathMl: false },
FORCE_BODY: true
});
const defaultOptions = {
preserveStyles: true,
scopeStyles: true,
addWrapper: true,
...options
};
// Extract style tags if we're preserving them
const styleTagsContent: string[] = [];
let processedHtml = html;
if (defaultOptions.preserveStyles) {
processedHtml = html.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (match, styleContent) => {
styleTagsContent.push(styleContent);
return ''; // Remove style tags temporarily
});
}
// Process the HTML content
processedHtml = processedHtml
// Remove potentially harmful elements and attributes
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
.replace(/<embed\b[^<]*(?:(?!<\/embed>)<[^<]*)*<\/embed>/gi, '')
.replace(/<form\b[^<]*(?:(?!<\/form>)<[^<]*)*<\/form>/gi, '')
.replace(/on\w+="[^"]*"/gi, '') // Remove inline event handlers (onclick, onload, etc.)
.replace(/on\w+='[^']*'/gi, '')
// Fix self-closing tags that might break React
.replace(/<(br|hr|img|input|link|meta|area|base|col|embed|keygen|param|source|track|wbr)([^>]*)>/gi, '<$1$2 />');
// If we're scoping styles, prefix classes
if (defaultOptions.scopeStyles) {
processedHtml = processedHtml.replace(/class=["']([^"']*)["']/gi, (match, classContent) => {
const classes = classContent.split(/\s+/).map((cls: string) => `email-forwarded-${cls}`).join(' ');
return `class="${classes}"`;
});
}
// Add scoped styles if needed
if (defaultOptions.preserveStyles && styleTagsContent.length > 0 && defaultOptions.scopeStyles) {
// Create a modified version of the styles that scope them to our container
const scopedStyles = styleTagsContent.map(style => {
// Replace CSS selectors to be scoped to our container
return style
// Add scope to class selectors
.replace(/(\.[a-zA-Z0-9_-]+)/g, '.email-forwarded-$1')
// Add scope to ID selectors
.replace(/(#[a-zA-Z0-9_-]+)/g, '#email-forwarded-$1')
// Fix any CSS that might break out
.replace(/@import/g, '/* @import */')
.replace(/@media/g, '/* @media */');
}).join('\n');
// Add the styles back in a scoped way
if (defaultOptions.addWrapper) {
return `
<div class="email-forwarded-content" style="position: relative; overflow: auto; max-width: 100%;">
<style type="text/css">
/* Scoped styles for forwarded email */
.email-forwarded-content {
/* Base containment */
font-family: Arial, sans-serif;
color: #333;
line-height: 1.5;
}
/* Original email styles (scoped) */
${scopedStyles}
</style>
${processedHtml}
</div>
`;
} else {
return `<style type="text/css">${scopedStyles}</style>${processedHtml}`;
}
}
// Just wrap the content if needed
if (defaultOptions.addWrapper) {
return `<div class="email-forwarded-content" style="position: relative; overflow: auto; max-width: 100%;">${processedHtml}</div>`;
}
return processedHtml;
} catch (error) {
console.error('Error cleaning HTML:', error);
return html;
// Return something safe in case of error
return `<div style="color: #666; font-style: italic;">Error processing HTML content</div>`;
}
}

View File

@ -1,20 +1,10 @@
import { simpleParser } from 'mailparser';
import { cleanHtml as cleanHtmlCentralized } from '@/lib/mail-parser-wrapper';
// This function is now deprecated in favor of the centralized cleanHtml in mail-parser-wrapper.ts
// It's kept here temporarily for backward compatibility
export function cleanHtml(html: string): string {
try {
// More permissive cleaning that preserves styling but removes potentially harmful elements
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
.replace(/<embed\b[^<]*(?:(?!<\/embed>)<[^<]*)*<\/embed>/gi, '')
.replace(/<form\b[^<]*(?:(?!<\/form>)<[^<]*)*<\/form>/gi, '')
.replace(/on\w+="[^"]*"/gi, '') // Remove inline event handlers (onclick, onload, etc.)
.replace(/on\w+='[^']*'/gi, '');
} catch (error) {
console.error('Error cleaning HTML:', error);
return html;
}
return cleanHtmlCentralized(html, { preserveStyles: true, scopeStyles: false });
}
function getAddressText(address: any): string | null {
@ -36,7 +26,7 @@ export async function parseEmail(emailContent: string) {
cc: getAddressText(parsed.cc),
bcc: getAddressText(parsed.bcc),
date: parsed.date || null,
html: parsed.html ? cleanHtml(parsed.html) : null,
html: parsed.html ? cleanHtml(parsed.html as string) : null,
text: parsed.text || null,
attachments: parsed.attachments || [],
headers: Object.fromEntries(parsed.headers)