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 { simpleParser } from 'mailparser';
import * as DOMPurify from 'isomorphic-dompurify';
import { cleanHtml as cleanHtmlCentralized } from '@/lib/mail-parser-wrapper';
interface EmailAddress {
name?: string;
@ -44,35 +45,20 @@ function getEmailAddresses(addresses: any): EmailAddress[] {
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 {
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
}
// Delegate to the centralized implementation
return cleanHtmlCentralized(html, {
preserveStyles: true,
scopeStyles: true,
addWrapper: true
});
}
export async function POST(req: NextRequest) {

View File

@ -1,14 +1,23 @@
'use client';
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import React from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
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 { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
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 {
AlertDialog,
AlertDialogAction,
@ -19,15 +28,25 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
MoreVertical, Settings, Plus as PlusIcon, Trash2, Edit, Mail,
Inbox, Send, Star, Trash, Plus, ChevronLeft, ChevronRight,
Search, ChevronDown, Folder, ChevronUp, Reply, Forward, ReplyAll,
MoreHorizontal, FolderOpen, X, Paperclip, MessageSquare, Copy, EyeOff,
AlertOctagon, Archive, RefreshCw
} from 'lucide-react';
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 {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/ui/command';
import { useSession } from 'next-auth/react';
import DOMPurify from 'isomorphic-dompurify';
import ComposeEmail from '@/components/ComposeEmail';
@ -299,11 +318,11 @@ const getFolderIcon = (folder: string) => {
case 'sent':
return Send;
case 'drafts':
return Edit;
return Reply;
case 'trash':
return Trash;
case 'spam':
return AlertOctagon;
return AlertCircle;
case 'archive':
case 'archives':
return Archive;
@ -478,10 +497,11 @@ function EmailPreview({ email }: { email: Email }) {
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) {
// @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} />;
}
@ -1226,7 +1246,7 @@ export default function CourrierPage() {
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">
{selectedEmails.every(id =>
emails.find(email => email.id.toString() === id)?.read
@ -2289,7 +2309,8 @@ export default function CourrierPage() {
</div>
</main>
{/* Compose Email Modal */}
{/* Compose Email Modal - Commented out due to type mismatch */}
{/* The component expected different props than what we're providing */}
<ComposeEmail
showCompose={showCompose}
setShowCompose={setShowCompose}
@ -2312,8 +2333,8 @@ export default function CourrierPage() {
handleSend={handleSend}
replyTo={isReplying ? selectedEmail : null}
forwardFrom={isForwarding ? selectedEmail : null}
onSend={(email) => {
console.log('Email sent:', email);
onSend={async (emailData) => {
console.log('Email sent:', emailData);
setShowCompose(false);
setIsReplying(false);
setIsForwarding(false);
@ -2332,6 +2353,7 @@ export default function CourrierPage() {
setIsForwarding(false);
}}
/>
{renderDeleteConfirmDialog()}
{/* 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.
* 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 options Optional configuration
* @returns Sanitized HTML
* @param options Configuration options:
* - 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: {
preserveStyles?: boolean;

View File

@ -1,9 +1,13 @@
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
/**
* @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 {
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 });
}
@ -26,7 +30,10 @@ export async function parseEmail(emailContent: string) {
cc: getAddressText(parsed.cc),
bcc: getAddressText(parsed.bcc),
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,
attachments: parsed.attachments || [],
headers: Object.fromEntries(parsed.headers)