panel 2 courier api restore
This commit is contained in:
parent
6edcb636c8
commit
b58539aeaa
@ -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());
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +1,83 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { simpleParser, AddressObject } from 'mailparser';
|
import { simpleParser } from 'mailparser';
|
||||||
|
import * as DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
function getEmailAddress(address: AddressObject | AddressObject[] | undefined): string | null {
|
interface EmailAddress {
|
||||||
if (!address) return null;
|
name?: string;
|
||||||
if (Array.isArray(address)) {
|
address: string;
|
||||||
return address.map(a => a.text).join(', ');
|
|
||||||
}
|
|
||||||
return address.text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the HTML to make it safe but preserve styles
|
// Helper to extract email addresses from mailparser Address objects
|
||||||
function processHtml(html: string | null): string | null {
|
function getEmailAddresses(addresses: any): EmailAddress[] {
|
||||||
if (!html) return null;
|
if (!addresses) return [];
|
||||||
|
|
||||||
try {
|
// Handle various address formats
|
||||||
// Make the content display well in the email context
|
if (Array.isArray(addresses)) {
|
||||||
return html
|
return addresses.map(addr => ({
|
||||||
// Fix self-closing tags that might break React
|
name: addr.name || undefined,
|
||||||
.replace(/<(br|hr|img|input|link|meta|area|base|col|embed|keygen|param|source|track|wbr)([^>]*)>/gi, '<$1$2 />')
|
address: addr.address
|
||||||
// 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;
|
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
|
||||||
});
|
});
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Error processing HTML:', error);
|
return result;
|
||||||
return html;
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// 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 {
|
try {
|
||||||
const body = await request.json();
|
const { email } = await req.json();
|
||||||
const { email } = body;
|
|
||||||
|
|
||||||
if (!email || typeof email !== 'string') {
|
if (!email || typeof email !== 'string') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -43,30 +88,39 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const parsed = await simpleParser(email);
|
const parsed = await simpleParser(email);
|
||||||
|
|
||||||
// Process the HTML to preserve styling but make it safe
|
// Process the HTML content to make it safe and displayable
|
||||||
// Handle the case where parsed.html could be a boolean
|
const html = parsed.html
|
||||||
const processedHtml = typeof parsed.html === 'string' ? processHtml(parsed.html) : null;
|
? 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({
|
return NextResponse.json({
|
||||||
subject: parsed.subject || null,
|
subject: parsed.subject,
|
||||||
from: getEmailAddress(parsed.from),
|
from: getEmailAddresses(parsed.from),
|
||||||
to: getEmailAddress(parsed.to),
|
to: getEmailAddresses(parsed.to),
|
||||||
cc: getEmailAddress(parsed.cc),
|
cc: getEmailAddresses(parsed.cc),
|
||||||
bcc: getEmailAddress(parsed.bcc),
|
bcc: getEmailAddresses(parsed.bcc),
|
||||||
date: parsed.date || null,
|
date: parsed.date,
|
||||||
html: processedHtml,
|
html,
|
||||||
text: parsed.textAsHtml || parsed.text || null,
|
text,
|
||||||
attachments: parsed.attachments?.map(att => ({
|
attachments
|
||||||
filename: att.filename,
|
|
||||||
contentType: att.contentType,
|
|
||||||
size: att.size
|
|
||||||
})) || [],
|
|
||||||
headers: parsed.headers || {}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing email:', error);
|
console.error('Error parsing email:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to parse email' },
|
{ error: 'Failed to parse email content' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* This is a debugging component that provides troubleshooting tools
|
* This is a debugging component that provides troubleshooting tools
|
||||||
* for the email loading process in the Courrier application.
|
* 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';
|
'use client';
|
||||||
|
|
||||||
@ -26,6 +29,11 @@ export function LoadingFix({
|
|||||||
loadEmails,
|
loadEmails,
|
||||||
emails
|
emails
|
||||||
}: LoadingFixProps) {
|
}: LoadingFixProps) {
|
||||||
|
// Don't render anything in production mode
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const forceResetLoadingStates = () => {
|
const forceResetLoadingStates = () => {
|
||||||
console.log('[DEBUG] Force resetting loading states to false');
|
console.log('[DEBUG] Force resetting loading states to false');
|
||||||
// Force both loading states to false
|
// Force both loading states to false
|
||||||
|
|||||||
@ -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 } {
|
function splitEmailHeadersAndBody(emailBody: string): { headers: string; body: string } {
|
||||||
const [headers, ...bodyParts] = emailBody.split('\r\n\r\n');
|
const [headers, ...bodyParts] = emailBody.split('\r\n\r\n');
|
||||||
return {
|
return {
|
||||||
@ -322,6 +326,15 @@ function formatDate(date: Date | null): string {
|
|||||||
}).format(date);
|
}).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' }) {
|
function ReplyContent({ email, type }: { email: Email; type: 'reply' | 'reply-all' | 'forward' }) {
|
||||||
const [content, setContent] = useState<string>('');
|
const [content, setContent] = useState<string>('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 }} />;
|
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 }) {
|
function EmailPreview({ email }: { email: Email }) {
|
||||||
const [preview, setPreview] = useState<string>('');
|
const [preview, setPreview] = useState<string>('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Update the generateEmailPreview function to use the new component
|
||||||
function generateEmailPreview(email: Email) {
|
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} />;
|
return <EmailPreview email={email} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -179,8 +179,7 @@ export default function ComposeEmail({
|
|||||||
: initialEmail.date.toLocaleString()
|
: initialEmail.date.toLocaleString()
|
||||||
: new Date().toLocaleString();
|
: new Date().toLocaleString();
|
||||||
|
|
||||||
// Create a clean wrapper that won't interfere with the original email's styling
|
// Create a clean header with inline styles only - no external CSS
|
||||||
// Use inline styles for the header to avoid CSS conflicts
|
|
||||||
const headerHtml = `
|
const headerHtml = `
|
||||||
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 15px; font-family: Arial, sans-serif; color: #333;">
|
<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;">
|
<div style="margin-bottom: 15px;">
|
||||||
@ -193,17 +192,16 @@ export default function ComposeEmail({
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Process the original content
|
// Default content is a clear "no content" message
|
||||||
let originalContent = '';
|
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
|
// Check if we have content to forward
|
||||||
|
if (initialEmail.content || initialEmail.html || initialEmail.text) {
|
||||||
try {
|
try {
|
||||||
// Use server-side parsing via fetch API to properly handle complex emails
|
// Use the parse-email API endpoint which centralizes our email parsing logic
|
||||||
const response = await fetch('/api/parse-email', {
|
const response = await fetch('/api/parse-email', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: initialEmail.content || initialEmail.html || initialEmail.text || ''
|
email: initialEmail.content || initialEmail.html || initialEmail.text || ''
|
||||||
}),
|
}),
|
||||||
@ -212,72 +210,65 @@ export default function ComposeEmail({
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const parsedEmail = await response.json();
|
const parsedEmail = await response.json();
|
||||||
|
|
||||||
if (parsedEmail.html && parsedEmail.html.trim()) {
|
// Use the parsed HTML content if available
|
||||||
console.log('Using parsed HTML content for forward');
|
if (parsedEmail.html) {
|
||||||
|
contentHtml = parsedEmail.html;
|
||||||
// Create an iframe-like containment for the email content
|
console.log('Successfully parsed HTML content');
|
||||||
// This prevents CSS from the original email leaking into our compose view
|
} else if (parsedEmail.text) {
|
||||||
originalContent = `
|
// Text-only content is wrapped in pre-formatted styling
|
||||||
<div class="email-content-container">
|
contentHtml = `<div style="white-space: pre-wrap; font-family: monospace; padding: 10px; background-color: #f9f9f9; border-radius: 4px;">${parsedEmail.text}</div>`;
|
||||||
${parsedEmail.html}
|
console.log('Using text content');
|
||||||
</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 {
|
} else {
|
||||||
console.log('No content available from parser');
|
console.warn('API returned success but no content');
|
||||||
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 {
|
} else {
|
||||||
throw new Error('Failed to parse email content');
|
console.error('API returned error:', await response.text());
|
||||||
|
throw new Error('API call failed');
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (error) {
|
||||||
console.error('Error parsing email content:', parseError);
|
console.error('Error parsing email:', error);
|
||||||
|
|
||||||
// Fall back to direct content handling if API parsing fails
|
// Fallback processing - using our cleanHtml utility directly
|
||||||
if (initialEmail.html && initialEmail.html.trim()) {
|
if (initialEmail.html) {
|
||||||
console.log('Falling back to HTML content for forward');
|
// Import the cleanHtml function dynamically if needed
|
||||||
// Use DOMPurify to sanitize HTML and remove dangerous elements
|
const { cleanHtml } = await import('@/lib/mail-parser-wrapper');
|
||||||
originalContent = DOMPurify.sanitize(initialEmail.html, {
|
contentHtml = cleanHtml(initialEmail.html, {
|
||||||
ADD_TAGS: ['style', 'div', 'span', 'p', 'br', 'hr', 'h1', 'h2', 'h3', 'img', 'table', 'tr', 'td', 'th'],
|
preserveStyles: true,
|
||||||
ADD_ATTR: ['style', 'class', 'id', 'src', 'alt', 'href', 'target'],
|
scopeStyles: true,
|
||||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
|
addWrapper: true
|
||||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
|
|
||||||
});
|
});
|
||||||
} else if (initialEmail.content && initialEmail.content.trim()) {
|
console.log('Using direct HTML cleaning fallback');
|
||||||
console.log('Falling back to content field for forward');
|
} else if (initialEmail.content) {
|
||||||
originalContent = DOMPurify.sanitize(initialEmail.content);
|
contentHtml = DOMPurify.sanitize(initialEmail.content, {
|
||||||
} else if (initialEmail.text && initialEmail.text.trim()) {
|
ADD_TAGS: ['style'],
|
||||||
console.log('Falling back to text content for forward');
|
FORBID_TAGS: ['script', 'iframe']
|
||||||
originalContent = `<div style="white-space: pre-wrap; font-family: monospace;">${initialEmail.text}</div>`;
|
});
|
||||||
} else {
|
console.log('Using DOMPurify sanitized content');
|
||||||
console.log('No content available for forward');
|
} else if (initialEmail.text) {
|
||||||
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>';
|
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
|
// Combine the header and content - using containment
|
||||||
// Important: We add a style scope to prevent CSS leakage
|
|
||||||
const forwardedContent = `
|
const forwardedContent = `
|
||||||
${headerHtml}
|
${headerHtml}
|
||||||
<!-- Start original email content - DO NOT MODIFY THIS CONTENT -->
|
<div style="margin-top: 10px; border-left: 2px solid #e1e1e1; padding-left: 15px;">
|
||||||
<div class="original-email-content" style="margin-top: 10px; border-left: 2px solid #e1e1e1; padding-left: 15px;">
|
<div style="isolation: isolate; contain: content; overflow: auto;">
|
||||||
<!-- Email content styling isolation container -->
|
${contentHtml}
|
||||||
<div style="position: relative; overflow: auto;">
|
|
||||||
${originalContent}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- End original email content -->
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log('Setting body with forwarded content');
|
console.log('Setting body with forwarded content');
|
||||||
setBody(forwardedContent);
|
setBody(forwardedContent);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing forwarded email:', 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(`
|
setBody(`
|
||||||
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 15px; font-family: Arial, sans-serif; color: #333;">
|
<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;">
|
<div style="margin-bottom: 15px;">
|
||||||
@ -288,7 +279,7 @@ export default function ComposeEmail({
|
|||||||
<div><b>To:</b> ${initialEmail.to ? formatRecipients(initialEmail.to) : ''}</div>
|
<div><b>To:</b> ${initialEmail.to ? formatRecipients(initialEmail.to) : ''}</div>
|
||||||
</div>
|
</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.
|
Error loading original message content. The original message may still be viewable in your inbox.
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|||||||
@ -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 {
|
try {
|
||||||
// Enhanced configuration to preserve more HTML elements for complex emails
|
const defaultOptions = {
|
||||||
return DOMPurify.sanitize(html, {
|
preserveStyles: true,
|
||||||
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'],
|
scopeStyles: true,
|
||||||
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'],
|
addWrapper: true,
|
||||||
ALLOW_UNKNOWN_PROTOCOLS: true,
|
...options
|
||||||
WHOLE_DOCUMENT: true,
|
};
|
||||||
KEEP_CONTENT: true,
|
|
||||||
RETURN_DOM: false,
|
// Extract style tags if we're preserving them
|
||||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'option', 'textarea', 'canvas', 'video', 'audio'],
|
const styleTagsContent: string[] = [];
|
||||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onchange', 'onsubmit'],
|
let processedHtml = html;
|
||||||
USE_PROFILES: { html: true, svg: false, svgFilters: false, mathMl: false },
|
|
||||||
FORCE_BODY: true
|
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) {
|
} catch (error) {
|
||||||
console.error('Error cleaning HTML:', 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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,20 +1,10 @@
|
|||||||
import { simpleParser } from 'mailparser';
|
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 {
|
export function cleanHtml(html: string): string {
|
||||||
try {
|
return cleanHtmlCentralized(html, { preserveStyles: true, scopeStyles: false });
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAddressText(address: any): string | null {
|
function getAddressText(address: any): string | null {
|
||||||
@ -36,7 +26,7 @@ export async function parseEmail(emailContent: string) {
|
|||||||
cc: getAddressText(parsed.cc),
|
cc: getAddressText(parsed.cc),
|
||||||
bcc: getAddressText(parsed.bcc),
|
bcc: getAddressText(parsed.bcc),
|
||||||
date: parsed.date || null,
|
date: parsed.date || null,
|
||||||
html: parsed.html ? cleanHtml(parsed.html) : null,
|
html: parsed.html ? cleanHtml(parsed.html as string) : null,
|
||||||
text: parsed.text || null,
|
text: parsed.text || null,
|
||||||
attachments: parsed.attachments || [],
|
attachments: parsed.attachments || [],
|
||||||
headers: Object.fromEntries(parsed.headers)
|
headers: Object.fromEntries(parsed.headers)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user