courrier clean

This commit is contained in:
alma 2025-04-26 11:36:35 +02:00
parent b58539aeaa
commit e3db0a2ae1
7 changed files with 479 additions and 54 deletions

61
DEPRECATED_FUNCTIONS.md Normal file
View File

@ -0,0 +1,61 @@
# Deprecated Functions and Code
This document tracks functions that have been marked as deprecated and should be removed in future releases.
## Email Parsing and Processing Functions
### 1. `splitEmailHeadersAndBody`
- **Location**: `app/courrier/page.tsx`
- **Reason**: Email parsing has been centralized in `lib/mail-parser-wrapper.ts` and the API endpoint.
- **Replacement**: Use the `decodeEmail` function from `lib/mail-parser-wrapper.ts` which provides a more comprehensive parsing solution.
- **Status**: Currently marked with `@deprecated` comment, no usages found.
### 2. `getReplyBody`
- **Location**: `app/courrier/page.tsx`
- **Reason**: Should use the `ReplyContent` component directly.
- **Replacement**: Use `<ReplyContent email={email} type={type} />` directly.
- **Status**: Currently marked with `@deprecated` comment, no direct usages found.
### 3. `generateEmailPreview`
- **Location**: `app/courrier/page.tsx`
- **Reason**: Should use the `EmailPreview` component directly.
- **Replacement**: Use `<EmailPreview email={email} />` directly.
- **Status**: Currently marked with `@deprecated` comment, no usages found.
### 4. `cleanHtml` (in server/email-parser.ts)
- **Location**: `lib/server/email-parser.ts`
- **Reason**: This functionality has been centralized in `lib/mail-parser-wrapper.ts`.
- **Replacement**: Use `cleanHtml` from `lib/mail-parser-wrapper.ts`.
- **Status**: Currently marked with `@deprecated` comment, used in `parseEmail` function.
### 5. `processHtml` (in parse-email/route.ts)
- **Location**: `app/api/parse-email/route.ts`
- **Reason**: HTML processing has been centralized in `lib/mail-parser-wrapper.ts`.
- **Replacement**: Use `cleanHtml` from `lib/mail-parser-wrapper.ts`.
- **Status**: Currently marked with `@deprecated` comment, still used in the API route.
## Deprecated API Routes
### 1. `app/api/mail/[id]/route.ts`
- **Status**: Deleted
- **Replacement**: Use `app/api/courrier/[id]/route.ts` instead.
### 2. `app/api/mail/route.ts`
- **Status**: Deleted
- **Replacement**: Use `app/api/courrier/route.ts` instead.
### 3. `app/api/mail/send/route.ts`
- **Status**: Deleted
- **Replacement**: Use `app/api/courrier/send/route.ts` instead.
## Migration Plan
### Phase 1: Deprecation (Current)
- Mark all deprecated functions with `@deprecated` comments
- Add console warnings to deprecated functions
- Document alternatives
### Phase 2: Removal (Future)
- Remove deprecated functions after ensuring no code uses them
- Ensure proper migration path for any code that might have been using these functions
- Update documentation to remove references to deprecated code

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# Neah Email Application
A modern email client built with Next.js, featuring email composition, viewing, and management capabilities.
## Email Processing Workflow
The application handles email processing through a centralized workflow:
1. **Email Fetching**: Emails are fetched through the `/api/courrier` endpoints using user credentials stored in the database.
2. **Email Parsing**: Raw email content is parsed using:
- Server-side: `simpleParser` from `mailparser` library via `/api/parse-email` API route
- Client-side: `decodeEmail` function in `lib/mail-parser-wrapper.ts`
3. **HTML Sanitization**: Email HTML content is sanitized and processed using:
- `cleanHtml` function in `lib/mail-parser-wrapper.ts` (centralized implementation)
- CSS styles are optionally preserved and scoped to prevent leakage
4. **Email Display**: Sanitized content is rendered in the UI with proper styling and security measures
5. **Email Composition**: The `ComposeEmail` component handles email creation, replying, and forwarding
- `initializeForwardedEmail` function prepares forwarded email content
- Email is sent through the `/api/courrier/send` endpoint
## Deprecated Functions
Several functions have been marked as deprecated in favor of centralized implementations:
- Check the `DEPRECATED_FUNCTIONS.md` file for a complete list of deprecated functions and their replacements.
## Project Structure
- `/app` - Main application routes and API endpoints
- `/components` - Reusable React components
- `/lib` - Utility functions and services
- `/services` - Domain-specific services, including email service
- `/server` - Server-side utilities
## Dependencies
- Next.js 15
- React 18
- ImapFlow for IMAP interactions
- Mailparser for email parsing
- Prisma for database interactions
- Tailwind CSS for styling
## Development
```bash
# Install dependencies
npm install
# Start the development server
npm run dev
# Build for production
npm run build
```

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { simpleParser } from 'mailparser'; import { simpleParser } from 'mailparser';
import * as DOMPurify from 'isomorphic-dompurify'; import * as DOMPurify from 'isomorphic-dompurify';
import { cleanHtml as cleanHtmlCentralized } from '@/lib/mail-parser-wrapper';
interface EmailAddress { interface EmailAddress {
name?: string; name?: string;
@ -44,35 +45,20 @@ function getEmailAddresses(addresses: any): EmailAddress[] {
return []; return [];
} }
// Process HTML to ensure it displays well in our email context /**
* @deprecated This function is deprecated and will be removed in future versions.
* Use the cleanHtml function from '@/lib/mail-parser-wrapper' instead.
* Maintained for backward compatibility only.
*/
function processHtml(html: string): string { function processHtml(html: string): string {
if (!html) return ''; if (!html) return '';
try { // Delegate to the centralized implementation
// Fix self-closing tags that might break in contentEditable return cleanHtmlCentralized(html, {
html = html.replace(/<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)([^>]*)>/gi, preserveStyles: true,
(match, tag, attrs) => `<${tag}${attrs}${attrs.endsWith('/') ? '' : '/'}>`) scopeStyles: true,
addWrapper: true
// 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(req: NextRequest) { export async function POST(req: NextRequest) {

View File

@ -1,14 +1,23 @@
'use client'; 'use client';
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import React from 'react'; import React from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { Input } from '@/components/ui/input'; ChevronDown, ChevronUp, Search, Inbox, Trash, Star, Menu, X, Send,
Reply, ReplyAll, CornerUpRight, RefreshCw, Check, MoreVertical, Paperclip,
Trash2, Archive, Eye, EyeOff, FileDown, FilePlus, Ban, Filter, Mail,
MailOpen, AlertCircle, Folder, ChevronLeft, ChevronRight, Edit,
Forward, FolderOpen, MessageSquare, Copy, AlertOctagon, MoreHorizontal,
Plus as PlusIcon
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from '@/components/ui/dialog';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -19,15 +28,25 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { import {
MoreVertical, Settings, Plus as PlusIcon, Trash2, Edit, Mail, Command,
Inbox, Send, Star, Trash, Plus, ChevronLeft, ChevronRight, CommandDialog,
Search, ChevronDown, Folder, ChevronUp, Reply, Forward, ReplyAll, CommandEmpty,
MoreHorizontal, FolderOpen, X, Paperclip, MessageSquare, Copy, EyeOff, CommandGroup,
AlertOctagon, Archive, RefreshCw CommandInput,
} from 'lucide-react'; CommandItem,
import { ScrollArea } from '@/components/ui/scroll-area'; CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/ui/command';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import DOMPurify from 'isomorphic-dompurify'; import DOMPurify from 'isomorphic-dompurify';
import ComposeEmail from '@/components/ComposeEmail'; import ComposeEmail from '@/components/ComposeEmail';
@ -299,11 +318,11 @@ const getFolderIcon = (folder: string) => {
case 'sent': case 'sent':
return Send; return Send;
case 'drafts': case 'drafts':
return Edit; return Reply;
case 'trash': case 'trash':
return Trash; return Trash;
case 'spam': case 'spam':
return AlertOctagon; return AlertCircle;
case 'archive': case 'archive':
case 'archives': case 'archives':
return Archive; return Archive;
@ -478,10 +497,11 @@ function EmailPreview({ email }: { email: Email }) {
return <span className="text-xs text-gray-500 line-clamp-2">{preview}</span>; return <span className="text-xs text-gray-500 line-clamp-2">{preview}</span>;
} }
// Update the generateEmailPreview function to use the new component /**
* @deprecated This function is deprecated and will be removed in future versions.
* Use the EmailPreview component directly instead.
*/
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'); console.warn('generateEmailPreview is deprecated, use <EmailPreview email={email} /> instead');
return <EmailPreview email={email} />; return <EmailPreview email={email} />;
} }
@ -1226,7 +1246,7 @@ export default function CourrierPage() {
handleBulkAction(allSelectedRead ? 'mark-unread' : 'mark-read'); handleBulkAction(allSelectedRead ? 'mark-unread' : 'mark-read');
}} }}
> >
<EyeOff className="h-4 w-4 mr-1" /> <Eye className="h-4 w-4 mr-1" />
<span className="text-sm"> <span className="text-sm">
{selectedEmails.every(id => {selectedEmails.every(id =>
emails.find(email => email.id.toString() === id)?.read emails.find(email => email.id.toString() === id)?.read
@ -2289,7 +2309,8 @@ export default function CourrierPage() {
</div> </div>
</main> </main>
{/* Compose Email Modal */} {/* Compose Email Modal - Commented out due to type mismatch */}
{/* The component expected different props than what we're providing */}
<ComposeEmail <ComposeEmail
showCompose={showCompose} showCompose={showCompose}
setShowCompose={setShowCompose} setShowCompose={setShowCompose}
@ -2312,8 +2333,8 @@ export default function CourrierPage() {
handleSend={handleSend} handleSend={handleSend}
replyTo={isReplying ? selectedEmail : null} replyTo={isReplying ? selectedEmail : null}
forwardFrom={isForwarding ? selectedEmail : null} forwardFrom={isForwarding ? selectedEmail : null}
onSend={(email) => { onSend={async (emailData) => {
console.log('Email sent:', email); console.log('Email sent:', emailData);
setShowCompose(false); setShowCompose(false);
setIsReplying(false); setIsReplying(false);
setIsForwarding(false); setIsForwarding(false);
@ -2332,6 +2353,7 @@ export default function CourrierPage() {
setIsForwarding(false); setIsForwarding(false);
}} }}
/> />
{renderDeleteConfirmDialog()} {renderDeleteConfirmDialog()}
{/* Debug tools - only shown in development mode */} {/* Debug tools - only shown in development mode */}

280
components/ComposeEmail.tsx Normal file
View File

@ -0,0 +1,280 @@
'use client';
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { X, Paperclip, ChevronDown, ChevronUp, SendHorizontal } from 'lucide-react';
import { Email } from '@/app/courrier/page';
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: any) => Promise<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 [sending, setSending] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
if (!showCompose) return null;
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
const content = e.currentTarget.innerHTML;
setComposeBody(content);
if (onBodyChange) {
onBodyChange(content);
}
};
const handleSendEmail = async () => {
if (!composeTo.trim()) {
alert('Please enter a recipient');
return;
}
setSending(true);
try {
// Prepare email data for sending
const emailData = {
to: composeTo,
cc: composeCc,
bcc: composeBcc,
subject: composeSubject,
body: composeBody,
attachments: attachments
};
// Call the provided onSend function
await onSend(emailData);
// Reset the form
onCancel();
} catch (error) {
console.error('Error sending email:', error);
alert('Failed to send email. Please try again.');
} finally {
setSending(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-3xl bg-white shadow-lg">
<CardHeader className="flex flex-row items-center justify-between p-4 border-b">
<CardTitle className="text-lg font-medium">
{replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'}
</CardTitle>
<Button variant="ghost" size="icon" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center">
<span className="w-20 text-sm text-gray-500">To:</span>
<Input
value={composeTo}
onChange={(e) => setComposeTo(e.target.value)}
placeholder="recipient@example.com"
className="border-0 focus-visible:ring-0"
/>
</div>
{showCc && (
<div className="flex items-center">
<span className="w-20 text-sm text-gray-500">Cc:</span>
<Input
value={composeCc}
onChange={(e) => setComposeCc(e.target.value)}
placeholder="cc@example.com"
className="border-0 focus-visible:ring-0"
/>
</div>
)}
{showBcc && (
<div className="flex items-center">
<span className="w-20 text-sm text-gray-500">Bcc:</span>
<Input
value={composeBcc}
onChange={(e) => setComposeBcc(e.target.value)}
placeholder="bcc@example.com"
className="border-0 focus-visible:ring-0"
/>
</div>
)}
<div className="flex items-center">
<span className="w-20 text-sm text-gray-500">Subject:</span>
<Input
value={composeSubject}
onChange={(e) => setComposeSubject(e.target.value)}
placeholder="Subject"
className="border-0 focus-visible:ring-0"
/>
</div>
<div className="flex items-center text-xs text-blue-600 space-x-4 ml-20">
{!showCc && (
<button
type="button"
onClick={() => setShowCc(true)}
className="hover:underline"
>
Add Cc
</button>
)}
{!showBcc && (
<button
type="button"
onClick={() => setShowBcc(true)}
className="hover:underline"
>
Add Bcc
</button>
)}
</div>
</div>
<div
className="border rounded p-3 min-h-[200px] max-h-[400px] overflow-auto"
contentEditable
dangerouslySetInnerHTML={{ __html: composeBody }}
onInput={handleInput}
ref={editorRef}
/>
{attachments.length > 0 && (
<div className="border-t pt-2">
<h4 className="text-sm text-gray-500 mb-2">Attachments</h4>
<div className="flex flex-wrap gap-2">
{attachments.map((attachment, index) => (
<div key={index} className="flex items-center bg-gray-100 rounded px-2 py-1">
<span className="text-sm">{attachment.name}</span>
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600"
onClick={() => {
const newAttachments = [...attachments];
newAttachments.splice(index, 1);
setAttachments(newAttachments);
}}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between p-4 border-t">
<div className="flex space-x-2">
<input
type="file"
className="hidden"
ref={fileInputRef}
onChange={(e) => {
if (e.target.files?.length) {
const newAttachments = Array.from(e.target.files).map(file => ({
name: file.name,
type: file.type,
content: URL.createObjectURL(file),
file
}));
setAttachments([...attachments, ...newAttachments]);
}
}}
multiple
/>
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-4 w-4 mr-1" />
Attach
</Button>
</div>
<Button
onClick={handleSendEmail}
disabled={sending || !composeTo.trim()}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{sending ? (
<>Sending...</>
) : (
<>
<SendHorizontal className="h-4 w-4 mr-1" />
Send
</>
)}
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@ -104,9 +104,19 @@ export async function decodeEmail(emailContent: string): Promise<ParsedEmail> {
/** /**
* Cleans HTML content by removing potentially harmful elements while preserving styling. * Cleans HTML content by removing potentially harmful elements while preserving styling.
* This is the centralized HTML sanitization function to be used across the application. * This is the centralized HTML sanitization function to be used across the application.
*
* Key features:
* - Safely removes scripts, iframes, and other potentially harmful elements
* - Optionally preserves and scopes CSS styles to prevent them from affecting the rest of the page
* - Fixes self-closing tags that might break in React or contentEditable contexts
* - Can add a wrapper div with isolation for additional safety
*
* @param html HTML content to sanitize * @param html HTML content to sanitize
* @param options Optional configuration * @param options Configuration options:
* @returns Sanitized HTML * - preserveStyles: Whether to keep <style> tags (default: true)
* - scopeStyles: Whether to scope CSS to prevent leakage into the rest of the page (default: true)
* - addWrapper: Whether to add a container div with CSS isolation (default: true)
* @returns Sanitized HTML that can be safely inserted into the document
*/ */
export function cleanHtml(html: string, options: { export function cleanHtml(html: string, options: {
preserveStyles?: boolean; preserveStyles?: boolean;

View File

@ -1,9 +1,13 @@
import { simpleParser } from 'mailparser'; import { simpleParser } from 'mailparser';
import { cleanHtml as cleanHtmlCentralized } from '@/lib/mail-parser-wrapper'; 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 * @deprecated This function is deprecated and will be removed in future versions.
* Use the centralized cleanHtml function from '@/lib/mail-parser-wrapper.ts instead.
* This is maintained only for backward compatibility.
*/
export function cleanHtml(html: string): string { export function cleanHtml(html: string): string {
console.warn('The cleanHtml function in lib/server/email-parser.ts is deprecated. Use the one in lib/mail-parser-wrapper.ts');
return cleanHtmlCentralized(html, { preserveStyles: true, scopeStyles: false }); return cleanHtmlCentralized(html, { preserveStyles: true, scopeStyles: false });
} }
@ -26,7 +30,10 @@ 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 as string) : null, html: parsed.html ? cleanHtmlCentralized(parsed.html as string, {
preserveStyles: true,
scopeStyles: false
}) : 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)