Compare commits

...

115 Commits

Author SHA1 Message Date
0cfccc55fc courrier refactor rebuild 2 2025-04-27 12:30:53 +02:00
7466037779 courrier refactor rebuild 2 2025-04-27 12:27:31 +02:00
c3284982b5 courrier refactor rebuild 2 2025-04-27 12:25:46 +02:00
021df6b169 courrier refactor rebuild 2 2025-04-27 12:15:39 +02:00
c92f593caa courrier refactor rebuild 2 2025-04-27 12:13:29 +02:00
de728b9139 courrier refactor rebuild 2 2025-04-27 12:06:54 +02:00
46d8220466 courrier refactor rebuild 2 2025-04-27 12:05:51 +02:00
45bbb8229f courrier refactor rebuild 2 2025-04-27 11:55:33 +02:00
80e5b3fdcf courrier refactor rebuild 2 2025-04-27 11:52:45 +02:00
2ba6a2717b courrier refactor rebuild 2 2025-04-27 11:50:36 +02:00
819955d24b courrier refactor rebuild 2 2025-04-27 11:48:57 +02:00
80d631eee0 courrier refactor rebuild 2 2025-04-27 11:47:05 +02:00
ec3b498233 courrier refactor rebuild 2 2025-04-27 11:43:40 +02:00
a1241a20fa courrier refactor rebuild 2 2025-04-27 11:39:12 +02:00
5ec5ad58df courrier refactor rebuild 2 2025-04-27 11:38:14 +02:00
9f82be44cc courrier refactor rebuild 2 2025-04-27 11:29:18 +02:00
c976b23a6c courrier refactor rebuild 2 2025-04-27 11:25:15 +02:00
e023d050d2 courrier refactor rebuild 2 2025-04-27 11:19:36 +02:00
4ef9268b86 courrier refactor rebuild 2 2025-04-27 11:03:34 +02:00
51a92f27dd courrier refactor rebuild 2 2025-04-27 10:47:10 +02:00
02c9e7054d courrier refactor rebuild 2 2025-04-27 10:40:43 +02:00
19cff1ce7c courrier refactor rebuild 2 2025-04-27 10:32:43 +02:00
a51a4c303d courrier refactor rebuild 2 2025-04-27 10:25:20 +02:00
b036530766 courrier refactor rebuild 2 2025-04-27 10:13:52 +02:00
76cc7b5bf2 courrier refactor rebuild 2 2025-04-27 10:11:20 +02:00
1c4b38ec8f courrier refactor rebuild 2 2025-04-27 10:02:33 +02:00
c993fe738e courrier refactor rebuild 2 2025-04-27 09:56:52 +02:00
3c738f179a courrier refactor rebuild preview 2025-04-27 00:44:45 +02:00
1d7f0b2b69 courrier refactor rebuild preview 2025-04-27 00:42:46 +02:00
bb97f9b364 courrier refactor rebuild preview 2025-04-27 00:40:58 +02:00
cb3e119a5d courrier refactor rebuild preview 2025-04-27 00:37:34 +02:00
88e03326af courrier refactor rebuild preview 2025-04-27 00:37:24 +02:00
034cf6da23 courrier refactor rebuild preview 2025-04-27 00:35:31 +02:00
bf7b02b903 courrier refactor rebuild preview 2025-04-27 00:34:02 +02:00
4ee8eb6662 courrier refactor rebuild preview 2025-04-27 00:31:37 +02:00
685d8b4a2f courrier refactor rebuild preview 2025-04-27 00:29:51 +02:00
9970c7b4c9 courrier refactor rebuild preview 2025-04-27 00:28:10 +02:00
3c4151335b courrier refactor rebuild preview 2025-04-27 00:24:24 +02:00
a2a088cf0f courrier refactor rebuild preview 2025-04-27 00:20:02 +02:00
c7f5e31b23 courrier refactor rebuild preview 2025-04-27 00:12:19 +02:00
3992718204 courrier refactor rebuild preview 2025-04-27 00:09:18 +02:00
ae1087f401 courrier refactor rebuild preview 2025-04-27 00:02:35 +02:00
c44ce9d41e courrier refactor 2025-04-26 23:58:55 +02:00
7fa72b5489 courrier refactor 2025-04-26 23:51:33 +02:00
87695eab03 courrier refactor 2025-04-26 23:49:09 +02:00
2beb44712c courrier refactor 2025-04-26 23:41:21 +02:00
7139d52100 courrier refactor 2025-04-26 23:34:49 +02:00
9befdd60c3 courrier refactor 2025-04-26 23:30:46 +02:00
4af36d63f9 courrier refactor 2025-04-26 23:25:19 +02:00
a30198cb2b courrier refactor 2025-04-26 23:18:51 +02:00
dcc2594195 courrier refactor 2025-04-26 23:11:58 +02:00
b056438814 courrier refactor 2025-04-26 23:06:39 +02:00
367b79bf0b courrier refactor 2025-04-26 22:59:41 +02:00
aefe858106 courrier refactor 2025-04-26 22:44:53 +02:00
6e66c2ac34 courrier clean 2$ 2025-04-26 22:08:44 +02:00
d09d8f0579 courrier clean 2$ 2025-04-26 22:05:41 +02:00
e95f078bbc courrier clean 2$ 2025-04-26 21:28:34 +02:00
88020ccfe5 courrier clean 2$ 2025-04-26 21:28:24 +02:00
96cf29b98b courrier clean 2$ 2025-04-26 21:25:39 +02:00
1add54f457 courrier clean 2$ 2025-04-26 21:19:52 +02:00
b14b03877c courrier clean 2$ 2025-04-26 21:18:29 +02:00
bc5809520d courrier clean 2$ 2025-04-26 21:16:03 +02:00
773c7759c4 courrier clean 2$ 2025-04-26 21:12:48 +02:00
fb0ab72675 courrier clean 2$ 2025-04-26 21:12:18 +02:00
1685946c07 courrier clean 2$ 2025-04-26 21:05:25 +02:00
e40df0ad87 courrier clean 2$ 2025-04-26 21:01:12 +02:00
91751b23ca courrier clean 2$ 2025-04-26 20:57:42 +02:00
ccf129f1a4 courrier clean 2$ 2025-04-26 20:51:29 +02:00
4bf9fcc165 courrier clean 2$ 2025-04-26 20:47:03 +02:00
ad9999fdd5 courrier clean 2$ 2025-04-26 20:44:11 +02:00
26e72f4f73 courrier clean 2$ 2025-04-26 20:38:34 +02:00
5686b9fb7d courrier clean 2$ 2025-04-26 20:31:21 +02:00
0bb4fac9f9 courrier clean 2$ 2025-04-26 20:19:08 +02:00
44f3b3072f courrier clean 2$ 2025-04-26 20:16:42 +02:00
104c0d9f94 courrier clean 2$ 2025-04-26 20:13:55 +02:00
d7bbb68b5e courrier clean 2$ 2025-04-26 20:09:09 +02:00
aeaa086b40 courrier clean 2$ 2025-04-26 19:40:07 +02:00
1663721c89 courrier clean 2$ 2025-04-26 19:37:32 +02:00
afc7f64027 courrier clean 2$ 2025-04-26 19:23:31 +02:00
4c9fcdeb29 courrier clean 2$ 2025-04-26 19:20:52 +02:00
3e6b8cae1f courrier clean 2$ 2025-04-26 18:46:21 +02:00
e3b946f7e9 courrier clean 2 2025-04-26 18:24:28 +02:00
ddf72cc4f0 courrier clean 2 2025-04-26 18:22:02 +02:00
8137b42c5f courrier clean 2 2025-04-26 18:19:22 +02:00
c0153a2aef courrier clean 2 2025-04-26 18:12:07 +02:00
d607cc54bb courrier clean 2 2025-04-26 15:03:46 +02:00
24bafdcce4 courrier clean 2 2025-04-26 14:54:26 +02:00
051bcb08a4 courrier clean 2 2025-04-26 14:52:19 +02:00
97fb21a632 courrier clean 2 2025-04-26 14:49:20 +02:00
626b35bb40 courrier clean 2 2025-04-26 14:45:14 +02:00
090e703214 courrier clean 2 2025-04-26 14:41:40 +02:00
71cc875f57 courrier clean 2 2025-04-26 14:39:04 +02:00
34d2aed721 courrier clean 2 2025-04-26 14:35:20 +02:00
9fba1ac1c3 courrier clean 2 2025-04-26 14:31:55 +02:00
f4112f3160 courrier clean 2 2025-04-26 14:28:56 +02:00
6f38d53335 courrier clean 2 2025-04-26 14:27:09 +02:00
59f9afe9fe courrier clean 2 2025-04-26 14:23:32 +02:00
0c5437a24f courrier clean 2 2025-04-26 14:17:44 +02:00
e3791fa583 courrier clean 2 2025-04-26 13:51:07 +02:00
b0387982bf courrier clean 2 2025-04-26 13:34:15 +02:00
ad1723cce9 courrier clean 2 2025-04-26 13:28:53 +02:00
a9dc2da891 courrier clean 2 2025-04-26 13:02:31 +02:00
3f5e3097d3 courrier clean 2 2025-04-26 12:57:45 +02:00
2c98417f50 courrier clean 2 2025-04-26 12:55:18 +02:00
10b08b5043 courrier clean 2 2025-04-26 12:47:44 +02:00
f4a77ecd25 courrier clean 2 2025-04-26 12:16:18 +02:00
4db3140ece courrier clean 2 2025-04-26 12:12:43 +02:00
c325d3cdf7 courrier clean 2 2025-04-26 12:03:36 +02:00
195f8b7115 courrier clean 2 2025-04-26 11:58:58 +02:00
6bacfa28da courrier clean 2 2025-04-26 11:52:42 +02:00
638a2ee895 courrier clean 2 2025-04-26 11:51:28 +02:00
f117d66626 courrier clean 2 2025-04-26 11:46:03 +02:00
7820663c78 courrier clean 2025-04-26 11:41:20 +02:00
e3db0a2ae1 courrier clean 2025-04-26 11:36:35 +02:00
b58539aeaa panel 2 courier api restore 2025-04-26 11:27:01 +02:00
43 changed files with 5554 additions and 3870 deletions

144
DEPRECATED_FUNCTIONS.md Normal file
View File

@ -0,0 +1,144 @@
# Deprecated Functions and Files
This document lists functions and files that have been deprecated and should not be used in new code.
## Deprecated Files
### 1. `lib/email-formatter.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `lib/utils/email-formatter.ts` instead
- **Reason**: Consolidated email formatting to a single source of truth
### 2. `lib/mail-parser-wrapper.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use functions from `lib/utils/email-formatter.ts` instead
- **Reason**: Consolidated email formatting and sanitization to a single source of truth
### 3. `lib/email-parser.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `lib/server/email-parser.ts` for parsing and `lib/utils/email-formatter.ts` for sanitization
- **Reason**: Consolidated email parsing and formatting to dedicated files
### 4. `lib/compose-mime-decoder.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `decodeComposeContent` and `encodeComposeContent` functions from `lib/utils/email-formatter.ts`
- **Reason**: Consolidated MIME handling into the centralized formatter
## Deprecated Functions
### 1. `formatEmailForReplyOrForward` in `lib/services/email-service.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: Use `formatEmailForReplyOrForward` from `lib/utils/email-formatter.ts`
- **Reason**: Consolidated email formatting to a single source of truth
### 2. `formatSubject` in `lib/services/email-service.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: None specific, handled by centralized formatter
- **Reason**: Internal function of the email formatter
### 3. `createQuoteHeader` in `lib/services/email-service.ts` (REMOVED)
- **Status**: Removed
- **Replacement**: None specific, handled by centralized formatter
- **Reason**: Internal function of the email formatter
## Centralized Email Formatting
All email formatting is now handled by the centralized formatter in `lib/utils/email-formatter.ts`. This file contains:
1. `formatForwardedEmail`: Format emails for forwarding
2. `formatReplyEmail`: Format emails for replying or replying to all
3. `formatEmailForReplyOrForward`: Compatibility function that maps to the above two
4. `sanitizeHtml`: Safely sanitize HTML content while preserving direction attributes
Use these functions for all email formatting needs.
## Email Parsing and Processing Functions
### 1. `splitEmailHeadersAndBody` (REMOVED)
- **Location**: Removed
- **Reason**: Email parsing has been centralized in `lib/server/email-parser.ts` and the API endpoint.
- **Replacement**: Use the `parseEmail` function from `lib/server/email-parser.ts` which provides a comprehensive parsing solution.
### 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` (REMOVED)
- **Location**: Removed from `lib/server/email-parser.ts`
- **Reason**: HTML sanitization has been consolidated in `lib/utils/email-formatter.ts`.
- **Replacement**: Use `sanitizeHtml` from `lib/utils/email-formatter.ts`.
### 5. `processHtml` (REMOVED)
- **Location**: Removed from `app/api/parse-email/route.ts`
- **Reason**: HTML processing has been consolidated in `lib/utils/email-formatter.ts`.
- **Replacement**: Use `sanitizeHtml` from `lib/utils/email-formatter.ts`.
## 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.
## Deprecated Components
### ComposeEmail (components/ComposeEmail.tsx)
**Status:** Deprecated since November 2023
**Replacement:** Use `components/email/ComposeEmail.tsx` instead
This component has been deprecated in favor of the more modular and better structured version in the email directory. The newer version has the following improvements:
- Better separation between user message and quoted content in replies/forwards
- Improved styling and visual hierarchy
- Support for RTL/LTR text direction toggling
- More modern UI using Card components instead of a modal
- Better state management for email composition
A compatibility layer has been added to the new component to ensure backward compatibility with existing code that uses the old component. This allows for a smooth transition without breaking changes.
**Migration Plan:**
1. Update imports in files using the old component to import from `@/components/email/ComposeEmail`
2. Test to ensure functionality works with the new component
3. Delete the old component file once all usages have been migrated
## Migration Plan
### Phase 1: Deprecation (Completed)
- Mark all deprecated functions with `@deprecated` comments
- Add console warnings to deprecated functions
- Document alternatives
### Phase 2: Removal (Completed)
- Remove deprecated files: `lib/email-parser.ts` and `lib/mail-parser-wrapper.ts`
- Consolidate all email formatting in `lib/utils/email-formatter.ts`
- All email parsing now in `lib/server/email-parser.ts`
- Update documentation to point to the centralized utilities
## Server-Client Code Separation
### Server-side imports in client components
- **Status**: Fixed in November 2023
- **Issue**: Server-only modules like ImapFlow were being imported directly in client components, causing build errors with messages like "Module not found: Can't resolve 'tls'"
- **Fix**:
1. Added 'use server' directive to server-only modules
2. Created client-safe interfaces in client components
3. Added server actions for email operations that need server capabilities
4. Refactored ComposeEmail component to avoid direct server imports
This architecture ensures a clean separation between server and client code, which is essential for Next.js applications, particularly with the App Router. It prevents Node.js-specific modules from being bundled into client-side JavaScript.

82
README.md Normal file
View File

@ -0,0 +1,82 @@
# 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: `parseEmail` function from `lib/server/email-parser.ts` (which uses `simpleParser` from the `mailparser` library)
- API route: `/api/parse-email` provides a REST interface to the parser
3. **HTML Sanitization**: Email HTML content is sanitized and processed using:
- `sanitizeHtml` function in `lib/utils/email-formatter.ts` (centralized implementation)
- Text direction (RTL/LTR) is preserved automatically during sanitization
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
- Uses the centralized formatter functions to prepare content
- Email is sent through the `/api/courrier/send` endpoint
## Deprecated Functions
Several functions have been deprecated and removed 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
- `/utils` - Utility functions including the centralized email formatter
## 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
```
# Email Formatting
## Centralized Email Formatter
All email formatting is now handled by a centralized formatter in `lib/utils/email-formatter.ts`. This ensures consistent handling of:
- Text direction (RTL/LTR)
- HTML sanitization
- Content formatting for forwards and replies
- MIME encoding and decoding for email composition
### Key Functions
- `formatForwardedEmail`: Format emails for forwarding
- `formatReplyEmail`: Format emails for replying
- `sanitizeHtml`: Sanitize HTML while preserving direction attributes
- `formatEmailForReplyOrForward`: Compatibility function for both
- `decodeComposeContent`: Parse MIME content for email composition
- `encodeComposeContent`: Create MIME-formatted content for sending emails
This centralized approach prevents formatting inconsistencies and direction problems when dealing with emails in different languages.

View File

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

View File

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

View File

@ -1,72 +1,72 @@
import { NextResponse } from 'next/server';
import { simpleParser, AddressObject } from 'mailparser';
import { NextRequest, NextResponse } from 'next/server';
import { parseEmail } from '@/lib/server/email-parser';
import { sanitizeHtml } from '@/lib/utils/email-formatter';
function getEmailAddress(address: AddressObject | AddressObject[] | undefined): string | null {
if (!address) return null;
if (Array.isArray(address)) {
return address.map(a => a.text).join(', ');
}
return address.text;
interface EmailAddress {
name?: string;
address: string;
}
// Clean up the HTML to make it safe but preserve styles
function processHtml(html: string | null): string | null {
if (!html) return null;
// Helper to extract email addresses from mailparser Address objects
function getEmailAddresses(addresses: any): EmailAddress[] {
if (!addresses) return [];
try {
// Make the content display well in the email context
return html
// Fix self-closing tags that might break React
.replace(/<(br|hr|img|input|link|meta|area|base|col|embed|keygen|param|source|track|wbr)([^>]*)>/gi, '<$1$2 />')
// Keep style tags but ensure they're closed properly
.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (match) => {
// Just return the matched style tag as-is
return match;
});
} catch (error) {
console.error('Error processing HTML:', error);
return html;
// Handle various address formats
if (Array.isArray(addresses)) {
return addresses.map(addr => ({
name: addr.name || undefined,
address: addr.address
}));
}
if (typeof addresses === 'object') {
const result: EmailAddress[] = [];
// Handle mailparser format with text, html, value properties
if (addresses.value) {
addresses.value.forEach((addr: any) => {
result.push({
name: addr.name || undefined,
address: addr.address
});
});
return result;
}
// Handle direct object with address property
if (addresses.address) {
return [{
name: addresses.name || undefined,
address: addresses.address
}];
}
}
return [];
}
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email || typeof email !== 'string') {
if (!body.email) {
return NextResponse.json(
{ error: 'Invalid email content' },
{ error: 'Missing email content' },
{ status: 400 }
);
}
const parsed = await simpleParser(email);
const parsedEmail = await parseEmail(body.email);
// Process the HTML to preserve styling but make it safe
// Handle the case where parsed.html could be a boolean
const processedHtml = typeof parsed.html === 'string' ? processHtml(parsed.html) : null;
// Process HTML content if available
if (parsedEmail.html) {
parsedEmail.html = sanitizeHtml(parsedEmail.html);
}
return NextResponse.json({
subject: parsed.subject || null,
from: getEmailAddress(parsed.from),
to: getEmailAddress(parsed.to),
cc: getEmailAddress(parsed.cc),
bcc: getEmailAddress(parsed.bcc),
date: parsed.date || null,
html: processedHtml,
text: parsed.textAsHtml || parsed.text || null,
attachments: parsed.attachments?.map(att => ({
filename: att.filename,
contentType: att.contentType,
size: att.size
})) || [],
headers: parsed.headers || {}
});
return NextResponse.json(parsedEmail);
} catch (error) {
console.error('Error parsing email:', error);
return NextResponse.json(
{ error: 'Failed to parse email' },
{ error: 'Failed to parse email', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -74,17 +74,241 @@
}
}
/* Email specific styles */
.email-content table { width: 100%; border-collapse: collapse; }
.email-content table.table-container { width: auto; margin-bottom: 20px; }
.email-content td, .email-content th { padding: 8px; border: 1px solid #e5e7eb; }
.email-content img { max-width: 100%; height: auto; }
.email-content div[style] { max-width: 100% !important; }
.email-content * { max-width: 100% !important; word-wrap: break-word; }
.email-content font { font-family: inherit; }
.email-content .total-row td { border-top: 1px solid #e5e7eb; }
.email-content a { color: #3b82f6; text-decoration: underline; }
.email-content p { margin-bottom: 0.75em; }
.email-content .header { margin-bottom: 1em; }
.email-content .footer { font-size: 0.875rem; color: #6b7280; margin-top: 1em; }
/* Email display styles */
.email-content-display {
max-width: 100%;
word-wrap: break-word;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-size: 14px;
}
/* Preserve email structure */
.email-content-display * {
max-width: 100% !important;
}
/* Images */
.email-content-display img {
max-width: 100%;
height: auto;
display: inline-block;
margin: 8px 0;
}
/* Tables */
.email-content-display table {
width: 100% !important;
border-collapse: collapse;
margin: 16px 0;
table-layout: fixed;
border: 1px solid #ddd;
}
/* Table wrapper for overflow handling */
.email-content-display div:has(> table) {
overflow-x: auto;
max-width: 100%;
margin: 16px 0;
}
.email-content-display td,
.email-content-display th {
padding: 8px;
border: 1px solid #ddd;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
font-size: 13px;
}
/* Make sure quoted content tables are properly displayed */
.email-content-display .quoted-content table,
.email-content-display blockquote table {
font-size: 12px;
margin: 8px 0;
width: 100% !important;
border: 1px solid #ddd;
}
.email-content-display .quoted-content td,
.email-content-display .quoted-content th,
.email-content-display blockquote td,
.email-content-display blockquote th {
padding: 6px;
font-size: 12px;
border: 1px solid #ddd;
}
/* Quote blocks for email replies */
.email-content-display blockquote,
.email-content-display .quoted-content {
margin: 16px 0;
padding: 8px 16px;
border-left: 2px solid #ddd;
color: #505050;
background-color: #f9f9f9;
border-radius: 4px;
font-size: 13px;
}
/* Special classes used in the email formatting functions */
.email-content-display .reply-body {
width: 100%;
font-family: Arial, sans-serif;
margin-top: 20px;
}
.email-content-display .quote-header {
color: #555;
font-size: 13px;
margin: 20px 0 10px 0;
font-weight: 400;
}
.email-content-display .quoted-content {
font-size: 13px;
line-height: 1.5;
}
.email-content-display .email-original-content {
margin-top: 10px;
padding-top: 10px;
}
/* Fix styles for the content in both preview and compose */
.email-content-display[contenteditable="false"] {
/* Same styles as contentEditable=true to ensure consistency */
white-space: pre-wrap;
word-break: break-word;
}
/* Quill editor customizations for email composition */
.ql-editor {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
padding: 12px;
overflow-y: auto !important;
}
/* Quote formatting for forwarded/replied emails */
.ql-editor blockquote {
border-left: 2px solid #ddd !important;
padding: 10px 0 10px 15px !important;
margin: 8px 0 !important;
color: #505050 !important;
background-color: #f9f9f9 !important;
border-radius: 4px !important;
font-size: 13px !important;
}
/* Table formatting in the editor */
.ql-editor table {
width: 100% !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 10px 0 !important;
border: 1px solid #ddd !important;
}
.ql-editor td,
.ql-editor th {
border: 1px solid #ddd !important;
padding: 6px 8px !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
min-width: 30px !important;
font-size: 13px !important;
}
/* Fix toolbar button styling */
.ql-toolbar.ql-snow {
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid #e5e7eb;
}
.ql-container.ql-snow {
border: none;
}
/* Style for "On [date], [person] wrote:" line */
.ql-editor div[style*="font-weight: 400"] {
margin-top: 20px !important;
margin-bottom: 8px !important;
color: #555 !important;
font-size: 13px !important;
}
/* Support for RTL content */
.email-content-display[dir="rtl"],
.email-content-display [dir="rtl"] {
text-align: right;
}
/* Remove any padding/margins from the first and last elements */
.email-content-display > *:first-child {
margin-top: 0;
}
.email-content-display > *:last-child {
margin-bottom: 0;
}
/* Forwarded message header styling */
.email-content-display div {
color: #555;
}
/* Forwarded message styling */
.email-content-display div[style*="forwarded message"],
.email-content-display div[class*="forwarded-message"],
.email-content-display div[class*="forwarded_message"] {
color: #555;
font-family: Arial, sans-serif;
margin-bottom: 15px;
}
/* Buttons */
.email-content-display button,
.email-content-display a[role="button"],
.email-content-display a.button,
.email-content-display div.button,
.email-content-display [class*="btn"],
.email-content-display [class*="button"] {
display: inline-block;
padding: 8px 16px;
background-color: #f97316;
color: white;
border-radius: 4px;
text-decoration: none;
font-weight: 500;
margin: 8px 0;
text-align: center;
cursor: pointer;
border: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Links */
.email-content-display a {
color: #3b82f6;
text-decoration: underline;
}
/* Headers and text */
.email-content-display h1,
.email-content-display h2,
.email-content-display h3 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.email-content-display p {
margin-bottom: 16px;
}

View File

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

View File

@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { Trash2, Archive, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface BulkActionsToolbarProps {
selectedCount: number;
onBulkAction: (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => void;
}
export default function BulkActionsToolbar({
selectedCount,
onBulkAction
}: BulkActionsToolbarProps) {
return (
<div className="bg-white border-b border-gray-100 px-4 py-2">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-gray-600">
{selectedCount} selected
</span>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-900 h-8 px-2"
onClick={() => onBulkAction('mark-read')}
>
<EyeOff className="h-4 w-4 mr-1" />
<span className="text-sm">Mark as read</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-900 h-8 px-2"
onClick={() => onBulkAction('archive')}
>
<Archive className="h-4 w-4 mr-1" />
<span className="text-sm">Archive</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 h-8 px-2"
onClick={() => onBulkAction('delete')}
>
<Trash2 className="h-4 w-4 mr-1" />
<span className="text-sm">Delete</span>
</Button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
import React from 'react';
import { SendHorizontal, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ComposeEmailFooterProps {
sending: boolean;
onSend: () => Promise<void>;
onCancel: () => void;
}
export default function ComposeEmailFooter({
sending,
onSend,
onCancel
}: ComposeEmailFooterProps) {
return (
<div className="p-4 border-t border-gray-200 flex justify-between items-center">
<div className="flex space-x-2">
<Button
type="button"
onClick={onSend}
disabled={sending}
className="bg-blue-600 hover:bg-blue-700"
>
{sending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<SendHorizontal className="mr-2 h-4 w-4" />
Send
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={sending}
>
Cancel
</Button>
</div>
<div className="text-xs text-gray-500">
{sending ? 'Sending your email...' : 'Ready to send'}
</div>
</div>
);
}

View File

@ -0,0 +1,227 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Paperclip, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
interface ComposeEmailFormProps {
to: string;
setTo: (value: string) => void;
cc: string;
setCc: (value: string) => void;
bcc: string;
setBcc: (value: string) => void;
subject: string;
setSubject: (value: string) => void;
emailContent: string;
setEmailContent: (value: string) => void;
showCc: boolean;
setShowCc: (value: boolean) => void;
showBcc: boolean;
setShowBcc: (value: boolean) => void;
attachments: Array<{
name: string;
content: string;
type: string;
}>;
onAttachmentAdd: (files: FileList) => void;
onAttachmentRemove: (index: number) => void;
}
export default function ComposeEmailForm({
to,
setTo,
cc,
setCc,
bcc,
setBcc,
subject,
setSubject,
emailContent,
setEmailContent,
showCc,
setShowCc,
showBcc,
setShowBcc,
attachments,
onAttachmentAdd,
onAttachmentRemove
}: ComposeEmailFormProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleAttachmentClick = () => {
fileInputRef.current?.click();
};
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
onAttachmentAdd(e.target.files);
}
// Reset the input value so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="p-4 space-y-4">
<div className="space-y-3">
{/* To field */}
<div className="flex items-center">
<Label htmlFor="to" className="w-16 flex-shrink-0">To:</Label>
<Input
id="to"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="Email address..."
className="flex-grow"
/>
</div>
{/* CC field - conditionally shown */}
{showCc && (
<div className="flex items-center">
<Label htmlFor="cc" className="w-16 flex-shrink-0">Cc:</Label>
<Input
id="cc"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="CC address..."
className="flex-grow"
/>
</div>
)}
{/* BCC field - conditionally shown */}
{showBcc && (
<div className="flex items-center">
<Label htmlFor="bcc" className="w-16 flex-shrink-0">Bcc:</Label>
<Input
id="bcc"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="BCC address..."
className="flex-grow"
/>
</div>
)}
{/* CC/BCC toggle buttons */}
<div className="flex items-center space-x-2 ml-16">
{!showCc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCc(true)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Add Cc <ChevronDown className="ml-1 h-3 w-3" />
</Button>
)}
{!showBcc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBcc(true)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Add Bcc <ChevronDown className="ml-1 h-3 w-3" />
</Button>
)}
{showCc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCc(false)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Remove Cc <ChevronUp className="ml-1 h-3 w-3" />
</Button>
)}
{showBcc && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBcc(false)}
className="h-6 px-2 text-xs text-gray-500 hover:text-gray-700"
>
Remove Bcc <ChevronUp className="ml-1 h-3 w-3" />
</Button>
)}
</div>
{/* Subject field */}
<div className="flex items-center">
<Label htmlFor="subject" className="w-16 flex-shrink-0">Subject:</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Email subject..."
className="flex-grow"
/>
</div>
</div>
{/* Email content */}
<Textarea
value={emailContent}
onChange={(e) => setEmailContent(e.target.value)}
placeholder="Write your message here..."
className="w-full min-h-[200px]"
/>
{/* Attachments */}
{attachments.length > 0 && (
<div className="border rounded-md p-2">
<h3 className="text-sm font-medium mb-2">Attachments</h3>
<div className="space-y-2">
{attachments.map((file, index) => (
<div key={index} className="flex items-center justify-between text-sm border rounded p-2">
<span className="truncate max-w-[200px]">{file.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onAttachmentRemove(index)}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Attachment input (hidden) */}
<input
type="file"
multiple
ref={fileInputRef}
onChange={handleFileSelection}
className="hidden"
/>
{/* Attachments button */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAttachmentClick}
className="flex items-center"
>
<Paperclip className="mr-2 h-4 w-4" />
Attach Files
</Button>
</div>
);
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ComposeEmailHeaderProps {
type: 'new' | 'reply' | 'reply-all' | 'forward';
onClose: () => void;
}
export default function ComposeEmailHeader({
type,
onClose
}: ComposeEmailHeaderProps) {
// Set the header title based on the compose type
const getTitle = () => {
switch (type) {
case 'reply':
return 'Reply';
case 'reply-all':
return 'Reply All';
case 'forward':
return 'Forward';
default:
return 'New Message';
}
};
return (
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">{getTitle()}</h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,135 @@
'use client';
import React, { useState } from 'react';
import { Loader2, Paperclip, Download } from 'lucide-react';
import { sanitizeHtml } from '@/lib/utils/email-formatter';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
interface EmailAddress {
name: string;
address: string;
}
interface Email {
id: string;
subject: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
date: Date | string;
content?: string;
html?: string;
text?: string;
hasAttachments?: boolean;
attachments?: Array<{
filename: string;
contentType: string;
size: number;
path?: string;
content?: string;
}>;
}
interface EmailContentProps {
email: Email;
}
export default function EmailContent({ email }: EmailContentProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Render attachments if they exist
const renderAttachments = () => {
if (!email?.attachments || email.attachments.length === 0) {
return null;
}
return (
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Attachments</h3>
<div className="grid grid-cols-2 gap-4">
{email.attachments.map((attachment, index) => (
<div key={index} className="flex items-center space-x-2 p-2 border rounded hover:bg-gray-50">
<Paperclip className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600 truncate flex-1">
{attachment.filename}
</span>
<Download className="h-4 w-4 text-gray-400 hover:text-gray-600 cursor-pointer" />
</div>
))}
</div>
</div>
);
};
if (isLoading) {
return (
<div className="flex justify-center items-center h-full p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<Alert variant="destructive" className="m-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
// Format the date for display
const formatDate = (dateObj: Date) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const date = new Date(dateObj);
const formattedTime = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (date >= today) {
return formattedTime;
} else if (date >= yesterday) {
return `Yesterday, ${formattedTime}`;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
return (
<div className="email-content-display p-6">
<div className="flex items-center gap-4 mb-6">
<Avatar className="h-10 w-10 bg-gray-100">
<AvatarFallback className="text-gray-600 font-medium">
{email.from?.[0]?.name?.[0] || email.from?.[0]?.address?.[0] || '?'}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-gray-900">
{email.from?.[0]?.name || email.from?.[0]?.address || 'Unknown'}
</p>
<p className="text-sm text-gray-500">
to {email.to?.[0]?.address || 'you'}
</p>
</div>
<div className="ml-auto text-sm text-gray-500">
{formatDate(new Date(email.date))}
</div>
</div>
{email.content ? (
<div className="email-content-display">
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(email.content) }} />
</div>
) : (
<p className="text-gray-500">No content available</p>
)}
{renderAttachments()}
</div>
);
}

View File

@ -0,0 +1,197 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import DOMPurify from 'isomorphic-dompurify';
import { parseRawEmail } from '@/lib/utils/email-mime-decoder';
interface EmailContentDisplayProps {
content: string;
type?: 'html' | 'text' | 'auto';
className?: string;
showQuotedText?: boolean;
}
/**
* Component for displaying properly formatted email content
* Handles MIME decoding, sanitization, and proper rendering
*/
const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
content,
type = 'auto',
className = '',
showQuotedText = true
}) => {
const [processedContent, setProcessedContent] = useState<{
html: string;
text: string;
isHtml: boolean;
}>({
html: '',
text: '',
isHtml: false
});
const containerRef = useRef<HTMLDivElement>(null);
// Process and sanitize email content
useEffect(() => {
if (!content) {
setProcessedContent({ html: '', text: '', isHtml: false });
return;
}
try {
// Check if this is raw email content
const isRawEmail = content.includes('Content-Type:') ||
content.includes('MIME-Version:') ||
content.includes('From:') && content.includes('To:');
if (isRawEmail) {
// Parse raw email content
const parsed = parseRawEmail(content);
// Check which content to use based on type and availability
const useHtml = (type === 'html' || (type === 'auto' && parsed.html)) && !!parsed.html;
if (useHtml) {
// Sanitize HTML content
const sanitizedHtml = DOMPurify.sanitize(parsed.html, {
ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'colspan', 'rowspan'],
ALLOW_DATA_ATTR: false
});
setProcessedContent({
html: sanitizedHtml,
text: parsed.text,
isHtml: true
});
} else {
// Format plain text with line breaks
const formattedText = parsed.text.replace(/\n/g, '<br />');
setProcessedContent({
html: formattedText,
text: parsed.text,
isHtml: false
});
}
} else {
// Treat as direct content (not raw email)
const isHtmlContent = content.includes('<html') ||
content.includes('<body') ||
content.includes('<div') ||
content.includes('<p>') ||
content.includes('<br');
if (isHtmlContent || type === 'html') {
// Sanitize HTML content
const sanitizedHtml = DOMPurify.sanitize(content, {
ADD_TAGS: ['table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'colspan', 'rowspan', 'style', 'class', 'id', 'border'],
ALLOW_DATA_ATTR: false
});
setProcessedContent({
html: sanitizedHtml,
text: content,
isHtml: true
});
} else {
// Format plain text with line breaks
const formattedText = content.replace(/\n/g, '<br />');
setProcessedContent({
html: formattedText,
text: content,
isHtml: false
});
}
}
} catch (err) {
console.error('Error processing email content:', err);
// Fallback to plain text
setProcessedContent({
html: content.replace(/\n/g, '<br />'),
text: content,
isHtml: false
});
}
}, [content, type]);
// Process quoted content visibility and fix table styling
useEffect(() => {
if (!containerRef.current || !processedContent.html) return;
const container = containerRef.current;
// Handle quoted text visibility
if (!showQuotedText) {
// Add toggle buttons for quoted text sections
const quotedSections = container.querySelectorAll('blockquote');
quotedSections.forEach((quote, index) => {
// Check if this quoted section already has a toggle
if (quote.previousElementSibling?.classList.contains('quoted-toggle-btn')) {
return;
}
// Create toggle button
const toggleBtn = document.createElement('button');
toggleBtn.innerText = '▼ Show quoted text';
toggleBtn.className = 'quoted-toggle-btn';
toggleBtn.style.cssText = 'background: none; border: none; color: #666; font-size: 12px; cursor: pointer; padding: 4px 0; display: block;';
// Hide quoted section initially
quote.style.display = 'none';
// Add click handler
toggleBtn.addEventListener('click', () => {
const isHidden = quote.style.display === 'none';
quote.style.display = isHidden ? 'block' : 'none';
toggleBtn.innerText = isHidden ? '▲ Hide quoted text' : '▼ Show quoted text';
});
// Insert before the blockquote
quote.parentNode?.insertBefore(toggleBtn, quote);
});
}
// Process tables and ensure they're properly formatted
const tables = container.querySelectorAll('table');
tables.forEach(table => {
// Cast to HTMLTableElement to access style property
const tableElement = table as HTMLTableElement;
// Only apply styling if the table doesn't already have border styles
if (!tableElement.hasAttribute('border') &&
(!tableElement.style.border || tableElement.style.border === '')) {
// Apply proper table styling
tableElement.style.width = '100%';
tableElement.style.borderCollapse = 'collapse';
tableElement.style.margin = '10px 0';
tableElement.style.border = '1px solid #ddd';
}
const cells = table.querySelectorAll('td, th');
cells.forEach(cell => {
// Cast to HTMLTableCellElement to access style property
const cellElement = cell as HTMLTableCellElement;
// Only apply styling if the cell doesn't already have border styles
if (!cellElement.style.border || cellElement.style.border === '') {
cellElement.style.border = '1px solid #ddd';
cellElement.style.padding = '6px';
}
});
});
}, [processedContent.html, showQuotedText]);
return (
<div
ref={containerRef}
className={`email-content-display ${className}`}
dangerouslySetInnerHTML={{ __html: processedContent.html }}
/>
);
};
export default EmailContentDisplay;

View File

@ -0,0 +1,163 @@
import React from 'react';
import {
ChevronLeft, Reply, ReplyAll, Forward, Star, MoreHorizontal
} from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Email } from '@/hooks/use-courrier';
interface EmailDetailViewProps {
email: Email;
onBack: () => void;
onReply: () => void;
onReplyAll: () => void;
onForward: () => void;
onToggleStar: () => void;
}
export default function EmailDetailView({
email,
onBack,
onReply,
onReplyAll,
onForward,
onToggleStar
}: EmailDetailViewProps) {
// Format date for display
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
// Render email content based on the email body
const renderEmailContent = () => {
try {
// For simple rendering in this example, we'll just display the content directly
return <div dangerouslySetInnerHTML={{ __html: email.content || '' }} />;
} catch (e) {
console.error('Error rendering email:', e);
return <div className="text-gray-500">Failed to render email content</div>;
}
};
return (
<>
{/* Email actions header */}
<div className="flex-none px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="md:hidden flex-shrink-0"
>
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="min-w-0 max-w-[500px]">
<h2 className="text-lg font-semibold text-gray-900 truncate">
{email.subject}
</h2>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-auto">
<div className="flex items-center border-l border-gray-200 pl-4">
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onReply}
>
<Reply className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onReplyAll}
>
<ReplyAll className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onForward}
>
<Forward className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-gray-900 h-9 w-9"
onClick={onToggleStar}
>
<Star className={`h-4 w-4 ${email.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
</div>
</div>
</div>
</div>
{/* Scrollable content area */}
<ScrollArea className="flex-1 p-6">
<div className="flex items-center gap-4 mb-6">
<Avatar className="h-10 w-10">
<AvatarFallback>
{(email.from?.[0]?.name || '').charAt(0) || (email.from?.[0]?.address || '').charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="font-medium text-gray-900">
{email.from?.[0]?.name || ''} <span className="text-gray-500">&lt;{email.from?.[0]?.address || ''}&gt;</span>
</p>
<p className="text-sm text-gray-500">
to {email.to?.[0]?.address || ''}
</p>
{email.cc && email.cc.length > 0 && (
<p className="text-sm text-gray-500">
cc {email.cc.map(c => c.address).join(', ')}
</p>
)}
</div>
<div className="text-sm text-gray-500 whitespace-nowrap">
{formatDate(email.date)}
</div>
</div>
{/* Email content */}
<div className="prose prose-sm max-w-none">
{renderEmailContent()}
</div>
{/* Attachments (if any) */}
{email.hasAttachments && email.attachments && email.attachments.length > 0 && (
<div className="mt-6 border-t border-gray-100 pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-2">Attachments</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{email.attachments.map((attachment, idx) => (
<div
key={idx}
className="flex items-center gap-2 p-2 border border-gray-200 rounded-md"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate">{attachment.filename}</p>
<p className="text-xs text-gray-500">{(attachment.size / 1024).toFixed(1)} KB</p>
</div>
</div>
))}
</div>
</div>
)}
</ScrollArea>
</>
);
}

View File

@ -0,0 +1,74 @@
import React from 'react';
import { AlertCircle } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
interface DeleteConfirmDialogProps {
show: boolean;
selectedCount: number;
onConfirm: () => Promise<void>;
onCancel: () => void;
}
export function DeleteConfirmDialog({
show,
selectedCount,
onConfirm,
onCancel
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={show} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedCount} email{selectedCount !== 1 ? 's' : ''}?</AlertDialogTitle>
<AlertDialogDescription>
This will move the selected email{selectedCount !== 1 ? 's' : ''} to the trash folder.
You can restore them later from the trash folder if needed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
interface LoginNeededAlertProps {
show: boolean;
onLogin: () => void;
onClose: () => void;
}
export function LoginNeededAlert({
show,
onLogin,
onClose
}: LoginNeededAlertProps) {
if (!show) return null;
return (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Please log in to your email account</AlertTitle>
<AlertDescription>
You need to connect your email account before you can access your emails.
</AlertDescription>
<div className="mt-2 flex gap-2">
<Button size="sm" onClick={onLogin}>Go to Login</Button>
<Button size="sm" variant="outline" onClick={onClose}>Dismiss</Button>
</div>
</Alert>
);
}

View File

@ -0,0 +1,119 @@
'use client';
import React, { useState } from 'react';
import { Search, X, Settings, Mail } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface EmailHeaderProps {
onSearch: (query: string) => void;
onSettingsClick?: () => void;
}
export default function EmailHeader({
onSearch,
onSettingsClick,
}: EmailHeaderProps) {
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
onSearch(searchQuery);
};
const clearSearch = () => {
setSearchQuery('');
onSearch('');
};
return (
<div className="border-b bg-white/95 backdrop-blur-sm flex flex-col">
{/* Courrier Title with improved styling */}
<div className="p-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<Mail className="h-6 w-6 text-blue-600" />
<span className="text-xl font-semibold text-gray-900">COURRIER</span>
</div>
</div>
<div className="px-4 py-2 flex items-center">
<div className="flex-1">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search emails..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-9 bg-gray-50 border-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</form>
</div>
<div className="ml-2 flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-600 hover:text-gray-900"
onClick={handleSearch}
>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Search</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-600 hover:text-gray-900">
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onSettingsClick}>
Email settings
</DropdownMenuItem>
<DropdownMenuItem onClick={onSettingsClick}>
Configure IMAP
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,177 @@
'use client';
import React, { useState } from 'react';
import { Loader2, Mail, Search, X } from 'lucide-react';
import { Email } from '@/hooks/use-courrier';
import EmailListItem from './EmailListItem';
import EmailListHeader from './EmailListHeader';
import BulkActionsToolbar from './BulkActionsToolbar';
import { Input } from '@/components/ui/input';
interface EmailListProps {
emails: Email[];
selectedEmailIds: string[];
selectedEmail: Email | null;
currentFolder: string;
isLoading: boolean;
totalEmails: number;
hasMoreEmails: boolean;
onSelectEmail: (emailId: string) => void;
onToggleSelect: (emailId: string) => void;
onToggleSelectAll: () => void;
onBulkAction: (action: 'delete' | 'mark-read' | 'mark-unread' | 'archive') => void;
onToggleStarred: (emailId: string) => void;
onLoadMore: () => void;
onSearch?: (query: string) => void;
}
export default function EmailList({
emails,
selectedEmailIds,
selectedEmail,
currentFolder,
isLoading,
totalEmails,
hasMoreEmails,
onSelectEmail,
onToggleSelect,
onToggleSelectAll,
onBulkAction,
onToggleStarred,
onLoadMore,
onSearch
}: EmailListProps) {
const [scrollPosition, setScrollPosition] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
// Handle scroll to detect when user reaches the bottom
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
const target = event.target as HTMLDivElement;
const { scrollTop, scrollHeight, clientHeight } = target;
setScrollPosition(scrollTop);
// If user scrolls near the bottom and we have more emails, load more
if (scrollHeight - scrollTop - clientHeight < 200 && hasMoreEmails && !isLoading) {
onLoadMore();
}
};
// Handle search
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
onSearch?.(searchQuery);
};
const clearSearch = () => {
setSearchQuery('');
onSearch?.('');
};
// Render loading state
if (isLoading && emails.length === 0) {
return (
<div className="flex justify-center items-center h-full p-8 bg-white/95 backdrop-blur-sm">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
}
// Render empty state
if (emails.length === 0) {
return (
<div className="flex flex-col justify-center items-center h-64 p-8 text-center bg-white/95 backdrop-blur-sm">
<Mail className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-gray-500 text-sm">
{searchQuery
? 'No emails match your search'
: currentFolder === 'INBOX'
? "Your inbox is empty. You're all caught up!"
: 'No emails in this folder'}
</p>
</div>
);
}
// Are all emails selected
const allSelected = selectedEmailIds.length === emails.length && emails.length > 0;
// Are some (but not all) emails selected
const someSelected = selectedEmailIds.length > 0 && selectedEmailIds.length < emails.length;
return (
<div className="w-[320px] bg-white/95 backdrop-blur-sm border-r border-gray-100 flex flex-col">
{/* Search header */}
<div className="border-b border-gray-100">
<div className="px-4 py-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
<form onSubmit={handleSearch}>
<Input
type="search"
placeholder="Search in folder..."
className="pl-8 h-9 bg-gray-50"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2"
>
<X className="h-4 w-4 text-gray-400" />
</button>
)}
</form>
</div>
</div>
<EmailListHeader
allSelected={allSelected}
someSelected={someSelected}
onToggleSelectAll={onToggleSelectAll}
currentFolder={currentFolder}
totalEmails={totalEmails}
/>
</div>
{selectedEmailIds.length > 0 && (
<BulkActionsToolbar
selectedCount={selectedEmailIds.length}
onBulkAction={onBulkAction}
/>
)}
<div
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
<div className="divide-y divide-gray-100">
{emails.map((email) => (
<EmailListItem
key={email.id}
email={email}
isSelected={selectedEmailIds.includes(email.id)}
isActive={selectedEmail?.id === email.id}
onSelect={() => onSelectEmail(email.id)}
onToggleSelect={(e: React.MouseEvent) => {
e.stopPropagation();
onToggleSelect(email.id);
}}
onToggleStarred={(e: React.MouseEvent) => {
e.stopPropagation();
onToggleStarred(email.id);
}}
/>
))}
{isLoading && emails.length > 0 && (
<div className="flex items-center justify-center p-4">
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-blue-500"></div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import React from 'react';
import { ChevronDown, Inbox } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
interface EmailListHeaderProps {
allSelected: boolean;
someSelected: boolean;
onToggleSelectAll: () => void;
currentFolder?: string;
totalEmails?: number;
}
export default function EmailListHeader({
allSelected,
someSelected,
onToggleSelectAll,
currentFolder = 'Inbox',
totalEmails = 0
}: EmailListHeaderProps) {
return (
<div className="flex items-center justify-between px-4 h-14">
<div className="flex items-center gap-3">
<Checkbox
checked={allSelected}
ref={(input) => {
if (input) {
(input as unknown as HTMLInputElement).indeterminate = someSelected && !allSelected;
}
}}
onCheckedChange={onToggleSelectAll}
className="mt-0.5"
/>
<h2 className="text-base font-semibold text-gray-900 capitalize">{currentFolder.toLowerCase()}</h2>
</div>
<span className="text-sm text-gray-600">
{totalEmails} {totalEmails === 1 ? 'email' : 'emails'}
</span>
</div>
);
}

View File

@ -0,0 +1,163 @@
'use client';
import React from 'react';
import { Star, Mail, MailOpen } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { Email } from '@/hooks/use-courrier';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
interface EmailListItemProps {
email: Email;
isSelected: boolean;
isActive: boolean;
onSelect: () => void;
onToggleSelect: (e: React.MouseEvent) => void;
onToggleStarred: (e: React.MouseEvent) => void;
}
export default function EmailListItem({
email,
isSelected,
isActive,
onSelect,
onToggleSelect,
onToggleStarred
}: EmailListItemProps) {
// Format the date in a readable way
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// Check if date is today
if (date >= today) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Check if date is yesterday
if (date >= yesterday) {
return 'Yesterday';
}
// Check if date is this year
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// Date is from a previous year
return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' });
};
// Get the first letter of the sender's name or email for the avatar
const getSenderInitial = () => {
if (!email.from || email.from.length === 0) return '?';
const sender = email.from[0];
if (sender.name && sender.name.trim()) {
return sender.name.trim()[0].toUpperCase();
}
if (sender.address && sender.address.trim()) {
return sender.address.trim()[0].toUpperCase();
}
return '?';
};
// Get sender name or email
const getSenderName = () => {
if (!email.from || email.from.length === 0) return 'Unknown';
const sender = email.from[0];
if (sender.name && sender.name.trim()) {
return sender.name.trim();
}
return sender.address || 'Unknown';
};
// Generate a stable color based on the sender's email
const getAvatarColor = () => {
if (!email.from || email.from.length === 0) return 'hsl(0, 0%, 50%)';
const address = email.from[0].address || '';
let hash = 0;
for (let i = 0; i < address.length; i++) {
hash = address.charCodeAt(i) + ((hash << 5) - hash);
}
const h = hash % 360;
return `hsl(${h}, 70%, 80%)`;
};
// Get preview text from email content
const getPreviewText = () => {
if (email.preview) return email.preview;
let content = email.content || '';
// Strip HTML tags if present
content = content.replace(/<[^>]+>/g, ' ');
// Clean up whitespace
content = content.replace(/\s+/g, ' ').trim();
// Limit to ~70 chars
if (content.length > 70) {
return content.substring(0, 70) + '...';
}
return content || 'No preview available';
};
return (
<div
className={cn(
'flex items-center gap-3 px-4 py-2 hover:bg-gray-50/80 cursor-pointer',
isActive ? 'bg-blue-50/50' : '',
!email.read ? 'bg-blue-50/20' : ''
)}
onClick={onSelect}
>
<Checkbox
checked={isSelected}
onClick={onToggleSelect}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className={`text-sm truncate ${!email.read ? 'font-semibold text-gray-900' : 'text-gray-600'}`}>
{getSenderName()}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-gray-500 whitespace-nowrap">
{formatDate(email.date)}
</span>
<button
className="h-6 w-6 text-gray-400 hover:text-yellow-400"
onClick={onToggleStarred}
>
<Star className={`h-4 w-4 ${email.starred ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</button>
</div>
</div>
<h3 className="text-sm text-gray-900 truncate">
{email.subject || '(No subject)'}
</h3>
<div className="text-xs text-gray-500 truncate">
{getPreviewText()}
</div>
</div>
</div>
);
}

View File

@ -1,10 +1,43 @@
'use client';
import { useState, useEffect } from 'react';
import { EmailMessage } from '@/lib/services/email-service';
import { useState, useEffect, useMemo } from 'react';
import EmailPreview from './EmailPreview';
import ComposeEmail from './ComposeEmail';
import { Loader2 } from 'lucide-react';
import { formatReplyEmail, EmailMessage as FormatterEmailMessage } from '@/lib/utils/email-formatter';
// Add local EmailMessage interface
interface EmailAddress {
name: string;
address: string;
}
interface EmailMessage {
id: string;
messageId?: string;
subject: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
date: Date | string;
flags?: {
seen: boolean;
flagged: boolean;
answered: boolean;
deleted: boolean;
draft: boolean;
};
preview?: string;
content?: string;
html?: string;
text?: string;
hasAttachments?: boolean;
attachments?: any[];
folder?: string;
size?: number;
contentFetched?: boolean;
}
interface EmailPanelProps {
selectedEmailId: string | null;
@ -36,6 +69,50 @@ export default function EmailPanel({
const [isComposing, setIsComposing] = useState<boolean>(false);
const [composeType, setComposeType] = useState<'new' | 'reply' | 'reply-all' | 'forward'>('new');
// Create a formatted version of the email content using the same formatter as ComposeEmail
const formattedEmail = useMemo(() => {
if (!email) return null;
try {
// Convert to the formatter message format - this is what ComposeEmail does
const formatterEmail: FormatterEmailMessage = {
id: email.id,
messageId: email.messageId,
subject: email.subject,
from: email.from || [],
to: email.to || [],
cc: email.cc || [],
bcc: email.bcc || [],
date: email.date,
content: email.content,
html: email.html,
text: email.text,
hasAttachments: email.hasAttachments || false
};
// Try both formatting approaches to match what ComposeEmail would display
// This handles preview, reply and forward cases
let formattedContent: string;
// ComposeEmail switches based on type - we need to do the same
const { content: replyContent } = formatReplyEmail(formatterEmail, 'reply');
// Set the formatted content
formattedContent = replyContent;
console.log("Generated formatted content for email preview");
// Return a new email object with the formatted content
return {
...email,
formattedContent
};
} catch (error) {
console.error('Error formatting email content:', error);
return email;
}
}, [email]);
// Load email content when selectedEmailId changes
useEffect(() => {
if (selectedEmailId) {
@ -167,10 +244,12 @@ export default function EmailPanel({
onSend={onSendEmail}
/>
) : (
<EmailPreview
email={email}
onReply={handleReply}
/>
<div className="max-w-4xl mx-auto h-full">
<EmailPreview
email={formattedEmail}
onReply={handleReply}
/>
</div>
)}
</div>
);

View File

@ -1,12 +1,62 @@
'use client';
import { useState, useEffect } from 'react';
import DOMPurify from 'isomorphic-dompurify';
import { EmailMessage } from '@/lib/services/email-service';
import { Loader2, Paperclip, Download } from 'lucide-react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { Loader2, Paperclip, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cleanHtml } from '@/lib/mail-parser-wrapper';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
formatReplyEmail,
formatForwardedEmail,
formatEmailForReplyOrForward,
EmailMessage as FormatterEmailMessage,
sanitizeHtml
} from '@/lib/utils/email-formatter';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { AvatarImage } from '@/components/ui/avatar';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { CalendarIcon, PaperclipIcon } from 'lucide-react';
import Link from 'next/link';
interface EmailAddress {
name: string;
address: string;
}
interface EmailMessage {
id: string;
messageId?: string;
subject: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
date: Date | string;
flags?: {
seen: boolean;
flagged: boolean;
answered: boolean;
deleted: boolean;
draft: boolean;
};
preview?: string;
content?: string;
html?: string;
text?: string;
formattedContent?: string;
hasAttachments?: boolean;
attachments?: Array<{
filename: string;
contentType: string;
size: number;
path?: string;
content?: string;
}>;
folder?: string;
size?: number;
contentFetched?: boolean;
}
interface EmailPreviewProps {
email: EmailMessage | null;
@ -15,38 +65,8 @@ interface EmailPreviewProps {
}
export default function EmailPreview({ email, loading = false, onReply }: EmailPreviewProps) {
const [contentLoading, setContentLoading] = useState<boolean>(false);
// Handle sanitizing and rendering HTML content
const renderContent = () => {
if (!email?.content) return <p>No content available</p>;
try {
// Use DOMPurify directly with enhanced sanitization options
const sanitizedContent = DOMPurify.sanitize(email.content, {
ADD_TAGS: ['style', 'meta', 'link', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'hr', 'font', 'div', 'span', 'a', 'img', 'b', 'strong', 'i', 'em', 'u', 'br', 'p', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'center', 'section', 'header', 'footer', 'article', 'nav', 'keyframes'],
ADD_ATTR: ['*', 'colspan', 'rowspan', 'cellpadding', 'cellspacing', 'border', 'bgcolor', 'width', 'height', 'align', 'valign', 'class', 'id', 'style', 'color', 'face', 'size', 'background', 'src', 'href', 'target', 'rel', 'alt', 'title', 'name', 'animation', 'animation-name', 'animation-duration', 'animation-fill-mode'],
ALLOW_UNKNOWN_PROTOCOLS: true,
WHOLE_DOCUMENT: true,
KEEP_CONTENT: true,
RETURN_DOM: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'option', 'textarea', 'canvas', 'video', 'audio'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onchange', 'onsubmit'],
USE_PROFILES: { html: true, svg: false, svgFilters: false, mathMl: false },
FORCE_BODY: true
});
return (
<div
className="email-content prose max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
);
} catch (error) {
console.error('Error rendering email content:', error);
return <p>Error displaying email content</p>;
}
};
// Add editorRef to match ComposeEmail exactly
const editorRef = useRef<HTMLDivElement>(null);
// Format the date
const formatDate = (date: Date | string) => {
@ -74,17 +94,64 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
).join(', ');
};
if (loading || contentLoading) {
// Get sender initials for avatar
const getSenderInitials = (name: string) => {
if (!name) return '';
return name
.split(" ")
.map((n) => n?.[0] || '')
.join("")
.toUpperCase()
.slice(0, 2);
};
// Format the email content using the same formatter as ComposeEmail
const formattedContent = useMemo(() => {
if (!email) return '';
try {
// Convert to the formatter message format - same as what ComposeEmail does
const formatterEmail: FormatterEmailMessage = {
id: email.id,
messageId: email.messageId,
subject: email.subject,
from: email.from || [],
to: email.to || [],
cc: email.cc || [],
bcc: email.bcc || [],
date: email.date instanceof Date ? email.date : new Date(email.date),
content: email.content || '',
html: email.html,
text: email.text,
hasAttachments: email.hasAttachments || false
};
// Get the formatted content - if already formatted content is provided, use that instead
if (email.formattedContent) {
return email.formattedContent;
}
// Otherwise sanitize the content for display
return sanitizeHtml(email.content || email.html || email.text || '');
} catch (error) {
console.error('Error formatting email content:', error);
return email.content || email.html || email.text || '';
}
}, [email]);
// Display loading state
if (loading) {
return (
<div className="flex items-center justify-center h-full p-6">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p>Loading email content...</p>
<p>Loading email...</p>
</div>
</div>
);
}
// No email selected
if (!email) {
return (
<div className="flex items-center justify-center h-full p-6">
@ -95,70 +162,74 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
);
}
const sender = email.from && email.from.length > 0 ? email.from[0] : undefined;
return (
<div className="flex flex-col h-full overflow-hidden">
<Card className="flex flex-col h-full overflow-hidden border-0 shadow-none">
{/* Email header */}
<div className="p-4 border-b">
<div className="mb-3">
<h2 className="text-xl font-semibold mb-2">{email.subject}</h2>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center">
<span className="font-medium mr-1">From:</span>
<span>{formatEmailAddresses(email.from)}</span>
<div className="p-6 border-b">
<div className="mb-4">
<h2 className="text-xl font-semibold mb-4">{email.subject}</h2>
<div className="flex items-start gap-3 mb-4">
<Avatar className="h-10 w-10">
<AvatarFallback>{getSenderInitials(sender?.name || '')}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium">{sender?.name || sender?.address}</div>
<span className="text-sm text-muted-foreground">{formatDate(email.date)}</span>
</div>
<div className="text-sm text-muted-foreground truncate mt-1">
To: {formatEmailAddresses(email.to)}
</div>
{email.cc && email.cc.length > 0 && (
<div className="text-sm text-muted-foreground truncate mt-1">
Cc: {formatEmailAddresses(email.cc)}
</div>
)}
</div>
<span className="text-muted-foreground">{formatDate(email.date)}</span>
</div>
{email.to && email.to.length > 0 && (
<div className="text-sm mt-1">
<span className="font-medium mr-1">To:</span>
<span>{formatEmailAddresses(email.to)}</span>
</div>
)}
{email.cc && email.cc.length > 0 && (
<div className="text-sm mt-1">
<span className="font-medium mr-1">Cc:</span>
<span>{formatEmailAddresses(email.cc)}</span>
{/* Action buttons */}
{onReply && (
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="outline"
onClick={() => onReply('reply')}
>
Reply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onReply('reply-all')}
>
Reply All
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onReply('forward')}
>
Forward
</Button>
</div>
)}
</div>
{/* Action buttons */}
{onReply && (
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="outline"
onClick={() => onReply('reply')}
>
Reply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onReply('reply-all')}
>
Reply All
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onReply('forward')}
>
Forward
</Button>
</div>
)}
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
<div className="mt-4 border-t pt-2">
<div className="text-sm font-medium mb-2">Attachments:</div>
<div className="px-6 py-3 border-b bg-muted/30">
<div className="text-sm font-medium mb-2">Attachments</div>
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, index) => (
<Badge key={index} variant="outline" className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
<Badge key={index} variant="outline" className="flex items-center gap-1 px-2 py-1">
<Paperclip className="h-3.5 w-3.5" />
<span>{attachment.filename}</span>
<span className="text-xs text-muted-foreground ml-1">
({Math.round(attachment.size / 1024)}KB)
@ -170,10 +241,19 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
)}
</div>
{/* Email content */}
<div className="flex-1 overflow-auto p-4">
{renderContent()}
</div>
</div>
{/* Email content - using the preformatted content */}
<ScrollArea className="flex-1">
<div className="space-y-2 p-6">
<div className="border rounded-md overflow-hidden">
<div
ref={editorRef}
contentEditable={false}
className="w-full p-4 min-h-[300px] focus:outline-none email-content-display"
dangerouslySetInnerHTML={{ __html: formattedContent }}
/>
</div>
</div>
</ScrollArea>
</Card>
);
}

View File

@ -0,0 +1,142 @@
'use client';
import React from 'react';
import {
Inbox, Send, Trash, Archive, Star,
File, RefreshCw, Plus, MailOpen, Settings
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
interface EmailSidebarProps {
currentFolder: string;
folders: string[];
onFolderChange: (folder: string) => void;
onRefresh: () => void;
onCompose: () => void;
isLoading: boolean;
}
export default function EmailSidebar({
currentFolder,
folders,
onFolderChange,
onRefresh,
onCompose,
isLoading
}: EmailSidebarProps) {
// Get the appropriate icon for a folder
const getFolderIcon = (folder: string) => {
const folderLower = folder.toLowerCase();
switch (folderLower) {
case 'inbox':
return <Inbox className="h-4 w-4" />;
case 'sent':
case 'sent items':
return <Send className="h-4 w-4" />;
case 'drafts':
return <File className="h-4 w-4" />;
case 'trash':
case 'deleted':
case 'bin':
return <Trash className="h-4 w-4" />;
case 'archive':
case 'archived':
return <Archive className="h-4 w-4" />;
case 'starred':
case 'important':
return <Star className="h-4 w-4" />;
default:
return <MailOpen className="h-4 w-4" />;
}
};
// Group folders into standard and custom
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Archive', 'Junk'];
const visibleStandardFolders = standardFolders.filter(f =>
folders.includes(f) || folders.some(folder => folder.toLowerCase() === f.toLowerCase())
);
const customFolders = folders.filter(f =>
!standardFolders.some(sf => sf.toLowerCase() === f.toLowerCase())
);
return (
<aside className="w-64 border-r h-full flex flex-col bg-white/95 backdrop-blur-sm">
{/* Compose button area */}
<div className="p-4">
<Button
className="w-full bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center py-2"
onClick={onCompose}
>
<Plus className="h-4 w-4 mr-2" />
New Email
</Button>
</div>
{/* Folder navigation */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{visibleStandardFolders.map((folder) => (
<Button
key={folder}
variant={currentFolder === folder ? "secondary" : "ghost"}
className={`w-full justify-start ${
currentFolder === folder ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => onFolderChange(folder)}
>
<div className="flex items-center w-full">
<span className="flex items-center">
{getFolderIcon(folder)}
<span className="ml-2 capitalize">{folder.toLowerCase()}</span>
</span>
{folder === 'INBOX' && (
<span className="ml-auto bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
{/* Unread count would go here */}
</span>
)}
</div>
</Button>
))}
{/* Custom folders section */}
{customFolders.length > 0 && (
<>
{customFolders.map(folder => (
<Button
key={folder}
variant={currentFolder === folder ? "secondary" : "ghost"}
className={`w-full justify-start ${
currentFolder === folder ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => onFolderChange(folder)}
>
<div className="flex items-center">
{getFolderIcon(folder)}
<span className="ml-2 truncate">{folder}</span>
</div>
</Button>
))}
</>
)}
</div>
</ScrollArea>
{/* Settings button (bottom) */}
<div className="p-2 border-t border-gray-100">
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-gray-600 hover:text-gray-900"
>
<Settings className="h-4 w-4 mr-2" />
<span>Email settings</span>
</Button>
</div>
</aside>
);
}

View File

@ -0,0 +1,68 @@
import React from 'react';
import {
Inbox, Send, Star, Trash, Folder,
AlertOctagon, Archive, Edit
} from 'lucide-react';
import { Button } from '@/components/ui/button';
interface EmailSidebarContentProps {
mailboxes: string[];
currentFolder: string;
onFolderChange: (folder: string) => void;
}
export default function EmailSidebarContent({
mailboxes,
currentFolder,
onFolderChange
}: EmailSidebarContentProps) {
// Helper to format folder names
const formatFolderName = (folder: string) => {
return folder.charAt(0).toUpperCase() + folder.slice(1).toLowerCase();
};
// Helper to get folder icons
const getFolderIcon = (folder: string) => {
const folderLower = folder.toLowerCase();
if (folderLower.includes('inbox')) {
return <Inbox className="h-4 w-4" />;
} else if (folderLower.includes('sent')) {
return <Send className="h-4 w-4" />;
} else if (folderLower.includes('trash')) {
return <Trash className="h-4 w-4" />;
} else if (folderLower.includes('archive')) {
return <Archive className="h-4 w-4" />;
} else if (folderLower.includes('draft')) {
return <Edit className="h-4 w-4" />;
} else if (folderLower.includes('spam') || folderLower.includes('junk')) {
return <AlertOctagon className="h-4 w-4" />;
} else {
return <Folder className="h-4 w-4" />;
}
};
return (
<nav className="p-3">
<ul className="space-y-0.5 px-2">
{mailboxes.map((folder) => (
<li key={folder}>
<Button
variant={currentFolder === folder ? 'secondary' : 'ghost'}
className={`w-full justify-start py-2 ${
currentFolder === folder ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900'
}`}
onClick={() => onFolderChange(folder)}
>
<div className="flex items-center">
{getFolderIcon(folder)}
<span className="ml-2">{formatFolderName(folder)}</span>
</div>
</Button>
</li>
))}
</ul>
</nav>
);
}

View File

@ -0,0 +1,144 @@
'use client';
import React from 'react';
import EmailContentDisplay from './EmailContentDisplay';
interface QuotedEmailContentProps {
content: string;
sender: {
name?: string;
email: string;
};
date: Date | string;
type: 'reply' | 'forward';
className?: string;
}
/**
* Component for displaying properly formatted quoted email content in replies and forwards
*/
const QuotedEmailContent: React.FC<QuotedEmailContentProps> = ({
content,
sender,
date,
type,
className = ''
}) => {
// Format the date
const formatDate = (date: Date | string) => {
if (!date) return '';
const dateObj = typeof date === 'string' ? new Date(date) : date;
try {
return dateObj.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return typeof date === 'string' ? date : date.toString();
}
};
// Format sender info
const senderName = sender.name || sender.email;
const formattedDate = formatDate(date);
// Create header based on type
const renderQuoteHeader = () => {
if (type === 'reply') {
return (
<div className="quote-header">
On {formattedDate}, {senderName} wrote:
</div>
);
} else {
return (
<div className="forward-header">
<div>---------- Forwarded message ---------</div>
<div><b>From:</b> {senderName} &lt;{sender.email}&gt;</div>
<div><b>Date:</b> {formattedDate}</div>
<div><b>Subject:</b> {/* Subject would be passed as a prop if needed */}</div>
<div><b>To:</b> {/* Recipients would be passed as a prop if needed */}</div>
</div>
);
}
};
return (
<div className={`quoted-email-container ${className}`}>
{renderQuoteHeader()}
<div className="quoted-content">
<EmailContentDisplay
content={content}
type="auto"
className="quoted-email-body"
showQuotedText={true}
/>
</div>
<style jsx>{`
.quoted-email-container {
margin-top: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
}
.quote-header {
color: #555;
font-size: 13px;
margin: 10px 0;
font-weight: 400;
}
.forward-header {
color: #555;
font-size: 13px;
margin-bottom: 15px;
font-family: Arial, sans-serif;
}
.forward-header div {
margin-bottom: 2px;
}
.quoted-content {
border-left: 2px solid #ddd;
padding: 0 0 0 15px;
margin: 10px 0;
color: #505050;
}
:global(.quoted-email-body) {
font-size: 13px;
line-height: 1.5;
}
:global(.quoted-email-body table) {
border-collapse: collapse;
width: 100%;
max-width: 100%;
margin-bottom: 1rem;
}
:global(.quoted-email-body th),
:global(.quoted-email-body td) {
padding: 0.5rem;
vertical-align: top;
border: 1px solid #dee2e6;
}
:global(.quoted-email-body th) {
font-weight: 600;
text-align: left;
background-color: #f8f9fa;
}
`}</style>
</div>
);
};
export default QuotedEmailContent;

View File

@ -0,0 +1,417 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import 'quill/dist/quill.snow.css';
import { sanitizeHtml } from '@/lib/utils/email-formatter';
interface RichEmailEditorProps {
initialContent: string;
onChange: (content: string) => void;
placeholder?: string;
minHeight?: string;
maxHeight?: string;
preserveFormatting?: boolean;
}
const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
initialContent,
onChange,
placeholder = 'Write your message here...',
minHeight = '200px',
maxHeight = 'calc(100vh - 400px)',
preserveFormatting = false,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<any>(null);
const [isReady, setIsReady] = useState(false);
// Initialize Quill editor when component mounts
useEffect(() => {
// Import Quill dynamically (client-side only)
const initializeQuill = async () => {
if (!editorRef.current || !toolbarRef.current) return;
const Quill = (await import('quill')).default;
// Import quill-better-table
let tableModule = null;
try {
const QuillBetterTable = await import('quill-better-table');
// Register the table module if available
if (QuillBetterTable && QuillBetterTable.default) {
Quill.register({
'modules/better-table': QuillBetterTable.default
}, true);
tableModule = QuillBetterTable.default;
console.log('Better Table module registered successfully');
}
} catch (err) {
console.warn('Table module not available:', err);
}
// Define custom formats/modules with table support
const emailToolbarOptions = [
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'align': [] }],
['link'],
['clean'],
];
// Create new Quill instance with the DOM element and custom toolbar
const editorElement = editorRef.current;
quillRef.current = new Quill(editorElement, {
modules: {
toolbar: {
container: toolbarRef.current,
handlers: {
// Add any custom toolbar handlers here
}
},
// Don't initialize better-table yet - we'll do it after content is loaded
'better-table': false,
},
placeholder: placeholder,
theme: 'snow',
});
// Set initial content (sanitized)
if (initialContent) {
try {
// First, ensure we preserve the raw HTML structure
const preservedContent = sanitizeHtml(initialContent);
// Check if there are tables in the content
const hasTables = preservedContent.includes('<table');
// For content with tables, we need special handling
if (hasTables && preserveFormatting && tableModule) {
// First, set the content directly to the root
quillRef.current.root.innerHTML = preservedContent;
// Initialize better table module after content is set
setTimeout(() => {
try {
// Clean up any existing tables first
const tables = quillRef.current.root.querySelectorAll('table');
tables.forEach((table: HTMLTableElement) => {
// Add required data attributes that the module expects
if (!table.getAttribute('data-table')) {
table.setAttribute('data-table', 'true');
}
});
// Initialize the module now that content is already in place
const betterTableModule = {
operationMenu: {
items: {
unmergeCells: {
text: 'Unmerge cells'
}
}
}
};
// Force a refresh
quillRef.current.update();
// Ensure the cursor and scroll position is at the top of the editor
quillRef.current.setSelection(0, 0);
// Also scroll the container to the top
if (editorRef.current) {
editorRef.current.scrollTop = 0;
// Also find and scroll parent containers that might have scroll
const scrollContainer = editorRef.current.closest('.ql-container');
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
// One more check for nested scroll containers (like overflow divs)
const parentScrollContainer = editorRef.current.closest('.rich-email-editor-container');
if (parentScrollContainer) {
parentScrollContainer.scrollTop = 0;
}
}
} catch (tableErr) {
console.error('Error initializing table module:', tableErr);
}
}, 100);
} else {
// For content without tables, use the standard paste method
quillRef.current.clipboard.dangerouslyPasteHTML(0, preservedContent);
quillRef.current.setSelection(0, 0);
}
} catch (err) {
console.error('Error setting initial content:', err);
// Fallback method if the above fails
quillRef.current.setText('');
quillRef.current.clipboard.dangerouslyPasteHTML(sanitizeHtml(initialContent));
quillRef.current.setSelection(0, 0);
}
}
// Add change listener
quillRef.current.on('text-change', () => {
const html = quillRef.current.root.innerHTML;
onChange(html);
});
// Improve editor layout
const editorContainer = editorElement.closest('.ql-container');
if (editorContainer) {
editorContainer.classList.add('email-editor-container');
}
setIsReady(true);
};
initializeQuill().catch(err => {
console.error('Failed to initialize Quill editor:', err);
});
// Clean up on unmount
return () => {
if (quillRef.current) {
// Clean up any event listeners or resources
quillRef.current.off('text-change');
}
};
}, []);
// Update content from props if changed externally
useEffect(() => {
if (quillRef.current && isReady) {
const currentContent = quillRef.current.root.innerHTML;
// Only update if content changed to avoid editor position reset
if (initialContent !== currentContent) {
try {
// Preserve cursor position if possible
const selection = quillRef.current.getSelection();
// First clear the content
quillRef.current.root.innerHTML = '';
// Then insert the new content at position 0
quillRef.current.clipboard.dangerouslyPasteHTML(0, sanitizeHtml(initialContent));
// Force update
quillRef.current.update();
// Restore selection if possible
if (selection) {
setTimeout(() => quillRef.current.setSelection(selection), 10);
}
} catch (err) {
console.error('Error updating content:', err);
// Fallback update method
quillRef.current.clipboard.dangerouslyPasteHTML(sanitizeHtml(initialContent));
}
}
}
}, [initialContent, isReady]);
return (
<div className="rich-email-editor-wrapper">
{/* Custom toolbar container */}
<div ref={toolbarRef} className="ql-toolbar ql-snow">
<span className="ql-formats">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
<button className="ql-strike"></button>
</span>
<span className="ql-formats">
<select className="ql-color"></select>
<select className="ql-background"></select>
</span>
<span className="ql-formats">
<button className="ql-list" value="ordered"></button>
<button className="ql-list" value="bullet"></button>
</span>
<span className="ql-formats">
<button className="ql-indent" value="-1"></button>
<button className="ql-indent" value="+1"></button>
</span>
<span className="ql-formats">
<select className="ql-align"></select>
</span>
<span className="ql-formats">
<button className="ql-link"></button>
</span>
<span className="ql-formats">
<button className="ql-clean"></button>
</span>
</div>
{/* Editor container with improved scrolling */}
<div className="rich-email-editor-container">
<div
ref={editorRef}
className="quill-editor"
/>
{/* Loading indicator */}
{!isReady && (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
</div>
)}
</div>
{/* Custom styles for email context */}
<style jsx>{`
.rich-email-editor-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 6px;
flex: 1;
}
.rich-email-editor-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
flex: 1;
position: relative;
}
.quill-editor {
width: 100%;
min-height: ${minHeight};
max-height: ${maxHeight};
overflow-y: auto;
overflow-x: hidden;
}
/* Hide the editor until it's ready */
.quill-editor ${!isReady ? '{ display: none; }' : ''}
/* Hide duplicate toolbar */
:global(.ql-toolbar.ql-snow + .ql-toolbar.ql-snow) {
display: none !important;
}
:global(.ql-container) {
border: none !important;
height: auto !important;
min-height: ${minHeight};
max-height: none !important;
overflow: visible;
}
:global(.ql-editor) {
padding: 12px;
min-height: ${minHeight};
overflow-y: auto !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #333 !important;
}
/* Ensure all text is visible */
:global(.ql-editor p),
:global(.ql-editor div),
:global(.ql-editor span),
:global(.ql-editor li) {
color: #333 !important;
}
/* Ensure placeholder text is visible but distinct */
:global(.ql-editor.ql-blank::before) {
color: #aaa !important;
font-style: italic !important;
}
/* Force blockquote styling */
:global(.ql-editor blockquote) {
border-left: 2px solid #ddd !important;
margin: 0 !important;
padding: 10px 0 10px 15px !important;
color: #505050 !important;
background-color: #f9f9f9 !important;
border-radius: 4px !important;
font-size: 13px !important;
}
/* Fix table rendering */
:global(.ql-editor table) {
width: 100% !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 10px 0 !important;
border: 1px solid #ddd !important;
}
:global(.ql-editor td),
:global(.ql-editor th) {
border: 1px solid #ddd !important;
padding: 6px 8px !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
min-width: 30px !important;
font-size: 13px !important;
}
/* Status styles for email displays */
:global(.ql-editor td[class*="status"]),
:global(.ql-editor td[class*="Status"]) {
background-color: #f8f9fa !important;
font-weight: 500 !important;
}
/* Amount styles */
:global(.ql-editor td[class*="amount"]),
:global(.ql-editor td[class*="Amount"]),
:global(.ql-editor td[class*="price"]),
:global(.ql-editor td[class*="Price"]) {
text-align: right !important;
font-family: monospace !important;
}
/* Header row styles */
:global(.ql-editor tr:first-child td),
:global(.ql-editor th) {
background-color: #f8f9fa !important;
font-weight: 600 !important;
}
/* Improve table cells with specific content */
:global(.ql-editor td:has(div[class*="number"])),
:global(.ql-editor td:has(div[class*="Number"])),
:global(.ql-editor td:has(div[class*="invoice"])),
:global(.ql-editor td:has(div[class*="Invoice"])) {
font-family: monospace !important;
letter-spacing: 0.5px !important;
}
/* Fix quoted paragraphs */
:global(.ql-editor blockquote p) {
margin-bottom: 8px !important;
margin-top: 0 !important;
}
/* Fix for reply headers */
:global(.ql-editor div[style*="font-weight: 400"]) {
margin-top: 20px !important;
margin-bottom: 8px !important;
color: #555 !important;
font-size: 13px !important;
}
`}</style>
</div>
);
};
export default RichEmailEditor;

0
email_changes.diff Normal file
View File

2
global.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
// Global type declarations
declare module 'quill-better-table';

466
hooks/use-courrier.ts Normal file
View File

@ -0,0 +1,466 @@
import { useState, useCallback, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useToast } from './use-toast';
import { formatEmailForReplyOrForward } from '@/lib/utils/email-formatter';
export interface EmailAddress {
name: string;
address: string;
}
export interface Email {
id: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
subject: string;
content: string;
preview?: string;
date: string;
read: boolean;
starred: boolean;
attachments?: { filename: string; contentType: string; size: number; content?: string }[];
folder: string;
hasAttachments: boolean;
contentFetched?: boolean;
}
export interface EmailListResult {
emails: Email[];
totalEmails: number;
page: number;
perPage: number;
totalPages: number;
folder: string;
mailboxes: string[];
}
export interface EmailData {
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
attachments?: Array<{
name: string;
content: string;
type: string;
}>;
}
export type MailFolder = string;
// Hook for managing email operations
export const useCourrier = () => {
// State for email data
const [emails, setEmails] = useState<Email[]>([]);
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null);
const [selectedEmailIds, setSelectedEmailIds] = useState<string[]>([]);
const [currentFolder, setCurrentFolder] = useState<MailFolder>('INBOX');
const [mailboxes, setMailboxes] = useState<string[]>([]);
// State for UI
const [isLoading, setIsLoading] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [totalEmails, setTotalEmails] = useState(0);
const [totalPages, setTotalPages] = useState(0);
// Auth and notifications
const { data: session } = useSession();
const { toast } = useToast();
// Load emails when folder or page changes
useEffect(() => {
if (session?.user?.id) {
loadEmails();
}
}, [currentFolder, page, perPage, session?.user?.id]);
// Load emails from the server
const loadEmails = useCallback(async (isLoadMore = false) => {
if (!session?.user?.id) return;
setIsLoading(true);
setError(null);
try {
// Build query params
const queryParams = new URLSearchParams({
folder: currentFolder,
page: page.toString(),
perPage: perPage.toString()
});
if (searchQuery) {
queryParams.set('search', searchQuery);
}
// Fetch emails from API
const response = await fetch(`/api/courrier?${queryParams.toString()}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch emails');
}
const data: EmailListResult = await response.json();
// Update state with the fetched data
if (isLoadMore) {
setEmails(prev => [...prev, ...data.emails]);
} else {
setEmails(data.emails);
}
setTotalEmails(data.totalEmails);
setTotalPages(data.totalPages);
// Update available mailboxes if provided
if (data.mailboxes && data.mailboxes.length > 0) {
setMailboxes(data.mailboxes);
}
// Clear selection if not loading more
if (!isLoadMore) {
setSelectedEmail(null);
setSelectedEmailIds([]);
}
} catch (err) {
console.error('Error loading emails:', err);
setError(err instanceof Error ? err.message : 'Failed to load emails');
toast({
variant: "destructive",
title: "Error",
description: err instanceof Error ? err.message : 'Failed to load emails'
});
} finally {
setIsLoading(false);
}
}, [currentFolder, page, perPage, searchQuery, session?.user?.id, toast]);
// Fetch a single email's content
const fetchEmailContent = useCallback(async (emailId: string) => {
try {
const response = await fetch(`/api/courrier/${emailId}?folder=${encodeURIComponent(currentFolder)}`);
if (!response.ok) {
throw new Error(`Failed to fetch email content: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching email content:', error);
throw error;
}
}, [currentFolder]);
// Select an email to view
const handleEmailSelect = useCallback(async (emailId: string) => {
setIsLoading(true);
try {
// Find the email in the current list
const email = emails.find(e => e.id === emailId);
if (!email) {
throw new Error('Email not found');
}
// If content is not fetched, get the full content
if (!email.contentFetched) {
const fullEmail = await fetchEmailContent(emailId);
// Merge the full content with the email
const updatedEmail = {
...email,
content: fullEmail.content,
attachments: fullEmail.attachments,
contentFetched: true
};
// Update the email in the list
setEmails(emails.map(e => e.id === emailId ? updatedEmail : e));
setSelectedEmail(updatedEmail);
} else {
setSelectedEmail(email);
}
// Mark the email as read if it's not already
if (!email.read) {
markEmailAsRead(emailId, true);
}
} catch (err) {
console.error('Error selecting email:', err);
toast({
variant: "destructive",
title: "Error",
description: "Could not load email content"
});
} finally {
setIsLoading(false);
}
}, [emails, fetchEmailContent, toast]);
// Mark an email as read/unread
const markEmailAsRead = useCallback(async (emailId: string, isRead: boolean) => {
try {
const response = await fetch(`/api/courrier/${emailId}/mark-read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
isRead,
folder: currentFolder
})
});
if (!response.ok) {
throw new Error('Failed to mark email as read');
}
// Update the email in the list
setEmails(emails.map(email =>
email.id === emailId ? { ...email, read: isRead } : email
));
// If the selected email is the one being marked, update it too
if (selectedEmail && selectedEmail.id === emailId) {
setSelectedEmail({ ...selectedEmail, read: isRead });
}
return true;
} catch (error) {
console.error('Error marking email as read:', error);
return false;
}
}, [emails, selectedEmail, currentFolder]);
// Toggle starred status for an email
const toggleStarred = useCallback(async (emailId: string) => {
const email = emails.find(e => e.id === emailId);
if (!email) return;
const newStarredStatus = !email.starred;
try {
const response = await fetch(`/api/courrier/${emailId}/star`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
starred: newStarredStatus,
folder: currentFolder
})
});
if (!response.ok) {
throw new Error('Failed to toggle star status');
}
// Update the email in the list
setEmails(emails.map(email =>
email.id === emailId ? { ...email, starred: newStarredStatus } : email
));
// If the selected email is the one being starred, update it too
if (selectedEmail && selectedEmail.id === emailId) {
setSelectedEmail({ ...selectedEmail, starred: newStarredStatus });
}
} catch (error) {
console.error('Error toggling star status:', error);
toast({
variant: "destructive",
title: "Error",
description: "Could not update star status"
});
}
}, [emails, selectedEmail, currentFolder, toast]);
// Send an email
const sendEmail = useCallback(async (emailData: EmailData) => {
if (!session?.user?.id) {
toast({
variant: "destructive",
title: "Error",
description: "You must be logged in to send emails"
});
return { success: false, error: "Not authenticated" };
}
setIsSending(true);
try {
const response = await fetch('/api/courrier/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(emailData)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to send email');
}
toast({
title: "Success",
description: "Email sent successfully"
});
return { success: true, messageId: result.messageId };
} catch (error) {
console.error('Error sending email:', error);
toast({
variant: "destructive",
title: "Error",
description: error instanceof Error ? error.message : 'Failed to send email'
});
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to send email'
};
} finally {
setIsSending(false);
}
}, [session?.user?.id, toast]);
// Delete selected emails
const deleteEmails = useCallback(async (emailIds: string[]) => {
if (emailIds.length === 0) return;
setIsDeleting(true);
try {
const response = await fetch('/api/courrier/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
emailIds,
folder: currentFolder
})
});
if (!response.ok) {
throw new Error('Failed to delete emails');
}
// Remove the deleted emails from the list
setEmails(emails.filter(email => !emailIds.includes(email.id)));
// Clear selection if the selected email was deleted
if (selectedEmail && emailIds.includes(selectedEmail.id)) {
setSelectedEmail(null);
}
// Clear selected IDs
setSelectedEmailIds([]);
toast({
title: "Success",
description: `${emailIds.length} email(s) deleted`
});
} catch (error) {
console.error('Error deleting emails:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete emails"
});
} finally {
setIsDeleting(false);
}
}, [emails, selectedEmail, currentFolder, toast]);
// Toggle selection of an email
const toggleEmailSelection = useCallback((emailId: string) => {
setSelectedEmailIds(prev => {
if (prev.includes(emailId)) {
return prev.filter(id => id !== emailId);
} else {
return [...prev, emailId];
}
});
}, []);
// Select all emails
const toggleSelectAll = useCallback(() => {
if (selectedEmailIds.length === emails.length) {
setSelectedEmailIds([]);
} else {
setSelectedEmailIds(emails.map(email => email.id));
}
}, [emails, selectedEmailIds]);
// Change the current folder
const changeFolder = useCallback((folder: MailFolder) => {
setCurrentFolder(folder);
setPage(1);
setSelectedEmail(null);
setSelectedEmailIds([]);
}, []);
// Search emails
const searchEmails = useCallback((query: string) => {
setSearchQuery(query);
setPage(1);
loadEmails();
}, [loadEmails]);
// Format an email for reply or forward
const formatEmailForAction = useCallback((email: Email, type: 'reply' | 'reply-all' | 'forward') => {
if (!email) return null;
return formatEmailForReplyOrForward(email, type);
}, []);
// Return all the functionality and state values
return {
// Data
emails,
selectedEmail,
selectedEmailIds,
currentFolder,
mailboxes,
isLoading,
isSending,
isDeleting,
error,
searchQuery,
page,
perPage,
totalEmails,
totalPages,
// Functions
loadEmails,
handleEmailSelect,
markEmailAsRead,
toggleStarred,
sendEmail,
deleteEmails,
toggleEmailSelection,
toggleSelectAll,
changeFolder,
searchEmails,
formatEmailForAction,
setPage,
setPerPage,
setSearchQuery,
};
};

View File

@ -0,0 +1,121 @@
'use server';
import { getEmails, EmailMessage, EmailAddress } from '@/lib/services/email-service';
import { formatEmailForReplyOrForward } from '@/lib/utils/email-formatter';
/**
* Server action to fetch emails
*/
export async function fetchEmails(userId: string, folder = 'INBOX', page = 1, perPage = 20, searchQuery = '') {
try {
const result = await getEmails(userId, folder, page, perPage, searchQuery);
return { success: true, data: result };
} catch (error) {
console.error('Error fetching emails:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch emails'
};
}
}
/**
* Server action to format email for reply or forward operations
* Uses the centralized email formatter
*/
export async function formatEmailServerSide(
email: {
id: string;
from: string;
fromName?: string;
to: string;
subject: string;
content: string;
cc?: string;
date: string;
},
type: 'reply' | 'reply-all' | 'forward'
) {
try {
// Convert the client email format to the server EmailMessage format
const serverEmail: EmailMessage = {
id: email.id,
subject: email.subject,
from: [
{
name: email.fromName || email.from.split('@')[0],
address: email.from
}
],
to: [
{
name: '',
address: email.to
}
],
cc: email.cc ? [
{
name: '',
address: email.cc
}
] : undefined,
date: new Date(email.date),
flags: {
seen: true,
flagged: false,
answered: false,
deleted: false,
draft: false
},
content: email.content,
hasAttachments: false,
folder: 'INBOX',
contentFetched: true
};
// Use the centralized formatter
const formatted = formatEmailForReplyOrForward(serverEmail, type);
return {
success: true,
data: formatted
};
} catch (error) {
console.error('Error formatting email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to format email'
};
}
}
/**
* Send an email from the server
*/
export async function sendEmailServerSide(
userId: string,
emailData: {
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
attachments?: Array<{
name: string;
content: string;
type: string;
}>;
}
) {
try {
const { sendEmail } = await import('@/lib/services/email-service');
const result = await sendEmail(userId, emailData);
return result;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to send email'
};
}
}

View File

@ -1,60 +0,0 @@
/**
* Simple MIME decoder for compose message box
* Handles basic email content without creating nested structures
*/
interface ParsedContent {
html: string | null;
text: string | null;
}
export async function decodeComposeContent(content: string): Promise<ParsedContent> {
if (!content.trim()) {
return { html: null, text: null };
}
try {
const response = await fetch('/api/parse-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: content }),
});
if (!response.ok) {
throw new Error('Failed to parse email');
}
const parsed = await response.json();
return {
html: parsed.html || null,
text: parsed.text || null
};
} catch (error) {
console.error('Error parsing email content:', error);
// Fallback to basic content handling
return {
html: content,
text: content
};
}
}
export async function encodeComposeContent(content: string): Promise<string> {
if (!content.trim()) {
throw new Error('Email content is empty');
}
// Create MIME headers
const mimeHeaders = {
'MIME-Version': '1.0',
'Content-Type': 'text/html; charset="utf-8"',
'Content-Transfer-Encoding': 'quoted-printable'
};
// Combine headers and content
return Object.entries(mimeHeaders)
.map(([key, value]) => `${key}: ${value}`)
.join('\n') + '\n\n' + content;
}

View File

@ -1,93 +0,0 @@
interface EmailHeaders {
from: string;
subject: string;
date: string;
to?: string;
}
export function parseEmailHeaders(headerContent: string): EmailHeaders {
const headers: { [key: string]: string } = {};
let currentHeader = '';
let currentValue = '';
// Split the header content into lines
const lines = headerContent.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// If line starts with whitespace, it's a continuation of the previous header
if (/^\s+/.test(line)) {
currentValue += ' ' + line.trim();
continue;
}
// If we have a current header being processed, save it
if (currentHeader && currentValue) {
headers[currentHeader.toLowerCase()] = currentValue.trim();
}
// Start processing new header
const match = line.match(/^([^:]+):\s*(.*)$/);
if (match) {
currentHeader = match[1];
currentValue = match[2];
}
}
// Save the last header
if (currentHeader && currentValue) {
headers[currentHeader.toLowerCase()] = currentValue.trim();
}
return {
from: headers['from'] || '',
subject: headers['subject'] || '',
date: headers['date'] || new Date().toISOString(),
to: headers['to']
};
}
export function decodeEmailBody(content: string, contentType: string): string {
try {
// Remove email client-specific markers
content = content.replace(/\r\n/g, '\n')
.replace(/=\n/g, '')
.replace(/=3D/g, '=')
.replace(/=09/g, '\t');
// If it's HTML content
if (contentType.includes('text/html')) {
return extractTextFromHtml(content);
}
return content;
} catch (error) {
console.error('Error decoding email body:', error);
return content;
}
}
function extractTextFromHtml(html: string): string {
// Remove scripts and style tags
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Convert <br> and <p> to newlines
html = html.replace(/<br[^>]*>/gi, '\n')
.replace(/<p[^>]*>/gi, '\n')
.replace(/<\/p>/gi, '\n');
// Remove all other HTML tags
html = html.replace(/<[^>]+>/g, '');
// Decode HTML entities
html = html.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
// Clean up whitespace
return html.replace(/\n\s*\n/g, '\n\n').trim();
}

View File

@ -1,123 +0,0 @@
'use client';
import DOMPurify from 'dompurify';
export interface ParsedEmail {
subject: string | null;
from: string | null;
to: string | null;
cc: string | null;
bcc: string | null;
date: Date | null;
html: string | null;
text: string | null;
attachments: Array<{
filename: string;
contentType: string;
size: number;
}>;
headers: Record<string, any>;
}
export async function decodeEmail(emailContent: string): Promise<ParsedEmail> {
try {
// Ensure the email content is properly formatted
const formattedContent = emailContent?.trim();
if (!formattedContent) {
return {
subject: null,
from: null,
to: null,
cc: null,
bcc: null,
date: null,
html: null,
text: 'No content available',
attachments: [],
headers: {}
};
}
const response = await fetch('/api/parse-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: formattedContent }),
});
const data = await response.json();
if (!response.ok) {
console.error('API Error:', data);
return {
subject: null,
from: null,
to: null,
cc: null,
bcc: null,
date: null,
html: null,
text: data.error || 'Failed to parse email',
attachments: [],
headers: {}
};
}
// If we have a successful response but no content
if (!data.html && !data.text) {
return {
...data,
date: data.date ? new Date(data.date) : null,
html: null,
text: 'No content available',
attachments: data.attachments || [],
headers: data.headers || {}
};
}
return {
...data,
date: data.date ? new Date(data.date) : null,
text: data.text || null,
html: data.html || null,
attachments: data.attachments || [],
headers: data.headers || {}
};
} catch (error) {
console.error('Error parsing email:', error);
return {
subject: null,
from: null,
to: null,
cc: null,
bcc: null,
date: null,
html: null,
text: 'Error parsing email content',
attachments: [],
headers: {}
};
}
}
export function cleanHtml(html: string): string {
try {
// Enhanced configuration to preserve more HTML elements for complex emails
return DOMPurify.sanitize(html, {
ADD_TAGS: ['style', 'meta', 'link', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'hr', 'font', 'div', 'span', 'a', 'img', 'b', 'strong', 'i', 'em', 'u', 'br', 'p', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'center', 'section', 'header', 'footer', 'article', 'nav', 'keyframes'],
ADD_ATTR: ['*', 'colspan', 'rowspan', 'cellpadding', 'cellspacing', 'border', 'bgcolor', 'width', 'height', 'align', 'valign', 'class', 'id', 'style', 'color', 'face', 'size', 'background', 'src', 'href', 'target', 'rel', 'alt', 'title', 'name', 'animation', 'animation-name', 'animation-duration', 'animation-fill-mode'],
ALLOW_UNKNOWN_PROTOCOLS: true,
WHOLE_DOCUMENT: true,
KEEP_CONTENT: true,
RETURN_DOM: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'select', 'option', 'textarea', 'canvas', 'video', 'audio'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onmouseout', 'onchange', 'onsubmit'],
USE_PROFILES: { html: true, svg: false, svgFilters: false, mathMl: false },
FORCE_BODY: true
});
} catch (error) {
console.error('Error cleaning HTML:', error);
return html;
}
}

View File

@ -1,30 +1,22 @@
import { sanitizeHtml } from '@/lib/utils/email-formatter';
import { simpleParser } from 'mailparser';
export function cleanHtml(html: string): string {
function getAddressText(addresses: any): string | null {
if (!addresses) return null;
try {
// More permissive cleaning that preserves styling but removes potentially harmful elements
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
.replace(/<embed\b[^<]*(?:(?!<\/embed>)<[^<]*)*<\/embed>/gi, '')
.replace(/<form\b[^<]*(?:(?!<\/form>)<[^<]*)*<\/form>/gi, '')
.replace(/on\w+="[^"]*"/gi, '') // Remove inline event handlers (onclick, onload, etc.)
.replace(/on\w+='[^']*'/gi, '');
if (Array.isArray(addresses)) {
return addresses.map(a => a.address || '').join(', ');
} else if (typeof addresses === 'object') {
return addresses.address || null;
}
return null;
} catch (error) {
console.error('Error cleaning HTML:', error);
return html;
console.error('Error formatting addresses:', error);
return null;
}
}
function getAddressText(address: any): string | null {
if (!address) return null;
if (Array.isArray(address)) {
return address.map(addr => addr.value?.[0]?.address || '').filter(Boolean).join(', ');
}
return address.value?.[0]?.address || null;
}
export async function parseEmail(emailContent: string) {
try {
const parsed = await simpleParser(emailContent);
@ -36,7 +28,7 @@ export async function parseEmail(emailContent: string) {
cc: getAddressText(parsed.cc),
bcc: getAddressText(parsed.bcc),
date: parsed.date || null,
html: parsed.html ? cleanHtml(parsed.html) : null,
html: parsed.html ? sanitizeHtml(parsed.html as string) : null,
text: parsed.text || null,
attachments: parsed.attachments || [],
headers: Object.fromEntries(parsed.headers)

View File

@ -1,3 +1,6 @@
'use server';
import 'server-only';
import { ImapFlow } from 'imapflow';
import nodemailer from 'nodemailer';
import { prisma } from '@/lib/prisma';
@ -625,134 +628,5 @@ export async function testEmailConnection(credentials: EmailCredentials): Promis
}
}
/**
* Format email for reply/forward
*/
export function formatEmailForReplyOrForward(
email: EmailMessage,
type: 'reply' | 'reply-all' | 'forward'
): {
to: string;
cc?: string;
subject: string;
body: string;
} {
// Format the subject with Re: or Fwd: prefix
const subject = formatSubject(email.subject, type);
// Create the email quote with proper formatting
const quoteHeader = createQuoteHeader(email);
const quotedContent = email.html || email.text || '';
// Format recipients
let to = '';
let cc = '';
if (type === 'reply') {
// Reply to sender only
to = email.from.map(addr => `${addr.name} <${addr.address}>`).join(', ');
} else if (type === 'reply-all') {
// Reply to sender and all recipients
to = email.from.map(addr => `${addr.name} <${addr.address}>`).join(', ');
// Add all original recipients to CC, except ourselves
const allRecipients = [
...(email.to || []),
...(email.cc || [])
];
cc = allRecipients
.map(addr => `${addr.name} <${addr.address}>`)
.join(', ');
} else if (type === 'forward') {
// Forward case doesn't need to set recipients
to = '';
// Instead, we format the content differently
const formattedDate = email.date ? new Date(email.date).toLocaleString() : '';
const fromText = email.from.map(f => f.name ? `${f.name} <${f.address}>` : f.address).join(', ');
const toText = email.to.map(t => t.name ? `${t.name} <${t.address}>` : t.address).join(', ');
// Return specialized body for forward
return {
to: '',
subject,
body: `
<div class="forwarded-message">
<p>---------- Forwarded message ---------</p>
<p>From: ${fromText}</p>
<p>Date: ${formattedDate}</p>
<p>Subject: ${email.subject || ''}</p>
<p>To: ${toText}</p>
<br>
${email.html || email.text ? (email.html || `<pre>${email.text || ''}</pre>`) : '<p>No content available</p>'}
</div>
`
};
}
// Format the email body with quote
const body = `
<div>
<br/>
<br/>
<div>${quoteHeader}</div>
<blockquote style="border-left: 2px solid #ccc; padding-left: 10px; margin-left: 10px; color: #777;">
${quotedContent}
</blockquote>
</div>`;
return {
to,
cc: cc || undefined,
subject,
body
};
}
/**
* Format subject with appropriate prefix (Re:, Fwd:)
*/
function formatSubject(subject: string, type: 'reply' | 'reply-all' | 'forward'): string {
// Clean up any existing prefixes
let cleanSubject = subject
.replace(/^(Re|Fwd|FW|Forward):\s*/i, '')
.trim();
// Add appropriate prefix
if (type === 'reply' || type === 'reply-all') {
if (!subject.match(/^Re:/i)) {
return `Re: ${cleanSubject}`;
}
} else if (type === 'forward') {
if (!subject.match(/^(Fwd|FW|Forward):/i)) {
return `Fwd: ${cleanSubject}`;
}
}
return subject;
}
/**
* Create a quote header for reply/forward
*/
function createQuoteHeader(email: EmailMessage): string {
// Format the date
const date = new Date(email.date);
const formattedDate = date.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Format the sender
const sender = email.from[0];
const fromText = sender?.name
? `${sender.name} <${sender.address}>`
: sender?.address || 'Unknown sender';
return `<div>On ${formattedDate}, ${fromText} wrote:</div>`;
}
// Email formatting functions have been moved to lib/utils/email-formatter.ts
// Use those functions instead of the ones previously defined here

View File

@ -0,0 +1,447 @@
/**
* CENTRAL EMAIL FORMATTING UTILITY
*
* This is the centralized email formatting utility used throughout the application.
* It provides consistent handling of email content, sanitization, and text direction.
*
* All code that needs to format email content should import from this file.
* Text direction is preserved based on content language for proper RTL/LTR display.
*/
import DOMPurify from 'isomorphic-dompurify';
// Instead of importing, implement the formatDateRelative function directly
// import { formatDateRelative } from './date-formatter';
/**
* Format a date in a relative format
* Simple implementation for email display
*/
function formatDateRelative(date: Date): string {
if (!date) return '';
try {
return date.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return date.toString();
}
}
// Reset any existing hooks to start clean
DOMPurify.removeAllHooks();
// Configure DOMPurify for English-only content (always LTR)
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
// We no longer force LTR direction on all elements
// This allows the natural text direction to be preserved
if (node instanceof HTMLElement) {
// Only set direction if not already specified
if (!node.hasAttribute('dir')) {
// Add dir attribute only if not present
node.setAttribute('dir', 'auto');
}
// Don't forcibly modify text alignment or direction in style attributes
// This allows the component to control text direction instead
}
});
// Configure DOMPurify to preserve direction attributes
DOMPurify.setConfig({
ADD_ATTR: ['dir'],
ALLOWED_ATTR: ['style', 'class', 'id', 'dir']
});
// Note: We ensure LTR text direction is applied in the component level
// when rendering email content
// Interface definitions
export interface EmailAddress {
name: string;
address: string;
}
export interface EmailMessage {
id: string;
messageId?: string;
subject: string;
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
date: Date | string;
flags?: {
seen: boolean;
flagged: boolean;
answered: boolean;
deleted: boolean;
draft: boolean;
};
preview?: string;
content?: string;
html?: string;
text?: string;
hasAttachments?: boolean;
attachments?: any[];
folder?: string;
size?: number;
contentFetched?: boolean;
}
/**
* Format email addresses for display
*/
export function formatEmailAddresses(addresses: EmailAddress[]): string {
if (!addresses || addresses.length === 0) return '';
return addresses.map(addr =>
addr.name && addr.name !== addr.address
? `${addr.name} <${addr.address}>`
: addr.address
).join(', ');
}
/**
* Format date for display
*/
export function formatEmailDate(date: Date | string | undefined): string {
if (!date) return '';
try {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return typeof date === 'string' ? date : date.toString();
}
}
/**
* Sanitize HTML content before processing or displaying
* This ensures the content is properly sanitized while preserving text direction
* @param html HTML content to sanitize
* @returns Sanitized HTML with preserved text direction
*/
export function sanitizeHtml(html: string): string {
if (!html) return '';
try {
// Use DOMPurify but ensure we keep all elements and attributes that might be in emails
const clean = DOMPurify.sanitize(html, {
ADD_TAGS: ['button', 'style', 'img', 'iframe', 'meta', 'table', 'thead', 'tbody', 'tr', 'td', 'th'],
ADD_ATTR: ['target', 'rel', 'style', 'class', 'id', 'href', 'src', 'alt', 'title', 'width', 'height', 'onclick', 'colspan', 'rowspan'],
KEEP_CONTENT: true,
WHOLE_DOCUMENT: false,
ALLOW_DATA_ATTR: true,
ALLOW_UNKNOWN_PROTOCOLS: true,
FORCE_BODY: false,
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
});
return clean;
} catch (e) {
console.error('Error sanitizing HTML:', e);
// Fall back to a basic sanitization approach
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+="[^"]*"/g, '');
}
}
/**
* Format an email for forwarding - CENTRAL IMPLEMENTATION
* All other formatting functions should be deprecated in favor of this one
*/
export function formatForwardedEmail(email: EmailMessage): {
subject: string;
content: string;
} {
// Format subject with Fwd: prefix if needed
const subjectBase = email.subject || '(No subject)';
const subject = subjectBase.match(/^(Fwd|FW|Forward):/i)
? subjectBase
: `Fwd: ${subjectBase}`;
// Get sender and recipient information
const fromString = formatEmailAddresses(email.from || []);
const toString = formatEmailAddresses(email.to || []);
const dateString = formatEmailDate(email.date);
// Get and sanitize original content (sanitization preserves content direction)
const originalContent = sanitizeHtml(email.content || email.html || email.text || '');
// Check if the content already has a forwarded message header
const hasExistingHeader = originalContent.includes('---------- Forwarded message ---------');
// If there's already a forwarded message header, don't add another one
if (hasExistingHeader) {
// Just wrap the content without additional formatting
const content = `
<div style="min-height: 20px;"></div>
<div class="email-original-content">
${originalContent}
</div>
`;
return { subject, content };
}
// Create formatted content for forwarded email
const content = `
<div style="min-height: 20px;">
<div style="border-top: 1px solid #ccc; margin-top: 10px; padding-top: 10px;">
<div style="font-family: Arial, sans-serif; color: #333;">
<div style="margin-bottom: 15px;">
<div>---------- Forwarded message ---------</div>
<div><b>From:</b> ${fromString}</div>
<div><b>Date:</b> ${dateString}</div>
<div><b>Subject:</b> ${email.subject || ''}</div>
<div><b>To:</b> ${toString}</div>
</div>
<div class="email-original-content">
${originalContent}
</div>
</div>
</div>
</div>
`;
return { subject, content };
}
/**
* Format an email for reply or reply-all - CENTRAL IMPLEMENTATION
* All other formatting functions should be deprecated in favor of this one
*/
export function formatReplyEmail(email: EmailMessage, type: 'reply' | 'reply-all'): {
to: string;
cc?: string;
subject: string;
content: string;
} {
// Format subject with Re: prefix if needed
const subjectBase = email.subject || '(No subject)';
const subject = subjectBase.match(/^Re:/i)
? subjectBase
: `Re: ${subjectBase}`;
// Get sender information for quote header
const sender = email.from[0];
const fromText = sender?.name
? `${sender.name} <${sender.address}>`
: sender?.address || 'Unknown sender';
// Format date for quote header
const date = typeof email.date === 'string' ? new Date(email.date) : email.date;
const formattedDate = date.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Create quote header
const quoteHeader = `<div style="font-weight: 400; color: #555; margin: 20px 0 8px 0; font-size: 13px;">On ${formattedDate}, ${fromText} wrote:</div>`;
// Get and sanitize original content (sanitization preserves content direction)
const originalContent = email.html || email.content || email.text || '';
const quotedContent = sanitizeHtml(originalContent);
// Format recipients
let to = formatEmailAddresses(email.from || []);
let cc = '';
if (type === 'reply-all') {
// For reply-all, add all original recipients to CC
const allRecipients = [
...(email.to || []),
...(email.cc || [])
];
cc = formatEmailAddresses(allRecipients);
}
// Format content for reply with improved styling
const content = `
<div style="min-height: 20px;"></div>
<div class="reply-body" style="font-family: Arial, sans-serif; line-height: 1.5;">
${quoteHeader}
<blockquote style="margin: 0; padding: 10px 0 10px 15px; border-left: 2px solid #ddd; color: #505050; background-color: #f9f9f9; border-radius: 4px;">
<div class="quoted-content" style="font-size: 13px;">
${quotedContent}
</div>
</blockquote>
</div>
`;
return {
to,
cc: cc || undefined,
subject,
content
};
}
/**
* COMPATIBILITY LAYER: For backward compatibility with the old email-formatter.ts
* These functions map to our new implementation but preserve the old interface
*/
export function formatEmailForReplyOrForward(
email: EmailMessage,
type: 'reply' | 'reply-all' | 'forward'
): {
to: string;
cc?: string;
subject: string;
body: string;
} {
if (type === 'forward') {
const { subject, content } = formatForwardedEmail(email);
return {
to: '',
subject,
body: content
};
} else {
const { to, cc, subject, content } = formatReplyEmail(email, type as 'reply' | 'reply-all');
return {
to,
cc,
subject,
body: content
};
}
}
/**
* Decode compose content from MIME format to HTML and text
*/
export async function decodeComposeContent(content: string): Promise<{
html: string | null;
text: string | null;
}> {
if (!content.trim()) {
return { html: null, text: null };
}
try {
const response = await fetch('/api/parse-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: content }),
});
if (!response.ok) {
throw new Error('Failed to parse email');
}
const parsed = await response.json();
// Apply LTR sanitization to the parsed content
return {
html: parsed.html ? sanitizeHtml(parsed.html) : null,
text: parsed.text || null
};
} catch (error) {
console.error('Error parsing email content:', error);
// Fallback to basic content handling with sanitization
return {
html: sanitizeHtml(content),
text: content
};
}
}
/**
* Encode compose content to MIME format for sending
*/
export function encodeComposeContent(content: string): string {
if (!content.trim()) {
throw new Error('Email content is empty');
}
// Create MIME headers
const mimeHeaders = {
'MIME-Version': '1.0',
'Content-Type': 'text/html; charset="utf-8"',
'Content-Transfer-Encoding': 'quoted-printable'
};
// Combine headers and content
return Object.entries(mimeHeaders)
.map(([key, value]) => `${key}: ${value}`)
.join('\n') + '\n\n' + content;
}
// Legacy email formatter functions - renamed to avoid conflicts
export function formatReplyEmailLegacy(email: any): string {
const originalSender = email.sender?.name || email.sender?.email || 'Unknown Sender';
const originalDate = formatDateRelative(new Date(email.date));
// Use our own sanitizeHtml function consistently
const sanitizedBody = sanitizeHtml(email.content || '');
return `
<p></p>
<p>On ${originalDate}, ${originalSender} wrote:</p>
<blockquote class="quoted-content">
${sanitizedBody}
</blockquote>
`.trim();
}
export function formatForwardedEmailLegacy(email: any): string {
const originalSender = email.sender?.name || email.sender?.email || 'Unknown Sender';
const originalRecipients = email.to?.map((recipient: any) =>
recipient.name || recipient.email
).join(', ') || 'Unknown Recipients';
const originalDate = formatDateRelative(new Date(email.date));
const originalSubject = email.subject || 'No Subject';
// Use our own sanitizeHtml function consistently
const sanitizedBody = sanitizeHtml(email.content || '');
return `
<p></p>
<p>---------- Forwarded message ---------</p>
<p><strong>From:</strong> ${originalSender}</p>
<p><strong>Date:</strong> ${originalDate}</p>
<p><strong>Subject:</strong> ${originalSubject}</p>
<p><strong>To:</strong> ${originalRecipients}</p>
<br>
<div class="email-original-content">
${sanitizedBody}
</div>
`.trim();
}
export function formatReplyToAllEmail(email: any): string {
// For reply all, we use the same format as regular reply
return formatReplyEmailLegacy(email);
}
// Utility function to get the reply subject line
export function getReplySubject(subject: string): string {
return subject.startsWith('Re:') ? subject : `Re: ${subject}`;
}
// Utility function to get the forward subject line
export function getForwardSubject(subject: string): string {
return subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`;
}

View File

@ -0,0 +1,275 @@
/**
* Email MIME Decoder
*
* This module provides functions to decode MIME-encoded email content
* for proper display in a frontend application.
*/
/**
* Decode a MIME encoded string (quoted-printable or base64)
* @param {string} text - The encoded text
* @param {string} encoding - The encoding type ('quoted-printable', 'base64', etc)
* @param {string} charset - The character set (utf-8, iso-8859-1, etc)
* @returns {string} - The decoded text
*/
export function decodeMIME(text: string, encoding?: string, charset = 'utf-8'): string {
if (!text) return '';
// Normalize encoding to lowercase
encoding = (encoding || '').toLowerCase();
charset = (charset || 'utf-8').toLowerCase();
try {
// Handle different encoding types
if (encoding === 'quoted-printable') {
return decodeQuotedPrintable(text, charset);
} else if (encoding === 'base64') {
return decodeBase64(text, charset);
} else {
// Plain text or other encoding
return text;
}
} catch (error) {
console.error('Error decoding MIME:', error);
return text; // Return original text if decoding fails
}
}
/**
* Decode a quoted-printable encoded string
* @param {string} text - The quoted-printable encoded text
* @param {string} charset - The character set
* @returns {string} - The decoded text
*/
export function decodeQuotedPrintable(text: string, charset: string): string {
// Replace soft line breaks (=\r\n or =\n)
let decoded = text.replace(/=(?:\r\n|\n)/g, '');
// Replace quoted-printable encoded characters
decoded = decoded.replace(/=([0-9A-F]{2})/gi, (match, p1) => {
return String.fromCharCode(parseInt(p1, 16));
});
// Handle character encoding
if (charset !== 'utf-8' && typeof TextDecoder !== 'undefined') {
try {
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i);
}
return new TextDecoder(charset).decode(bytes);
} catch (e) {
console.warn('TextDecoder error:', e);
}
}
return decoded;
}
/**
* Decode a base64 encoded string
* @param {string} text - The base64 encoded text
* @param {string} charset - The character set
* @returns {string} - The decoded text
*/
export function decodeBase64(text: string, charset: string): string {
// Remove whitespace that might be present in the base64 string
const cleanText = text.replace(/\s/g, '');
try {
// Use built-in atob function and TextDecoder for charset handling
const binary = atob(cleanText);
if (charset !== 'utf-8' && typeof TextDecoder !== 'undefined') {
// If TextDecoder is available and the charset is not utf-8
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder(charset).decode(bytes);
}
return binary;
} catch (e) {
console.error('Base64 decoding error:', e);
return text;
}
}
/**
* Parse email headers to extract content type, encoding and charset
* @param {string} headers - The raw email headers
* @returns {Object} - Object containing content type, encoding and charset
*/
export function parseEmailHeaders(headers: string): {
contentType: string;
encoding: string;
charset: string;
} {
const result = {
contentType: 'text/plain',
encoding: 'quoted-printable',
charset: 'utf-8'
};
// Extract content type
const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*charset=([^;]+))?/i);
if (contentTypeMatch) {
result.contentType = contentTypeMatch[1].trim().toLowerCase();
if (contentTypeMatch[2]) {
result.charset = contentTypeMatch[2].trim().replace(/"/g, '').toLowerCase();
}
}
// Extract content transfer encoding
const encodingMatch = headers.match(/Content-Transfer-Encoding:\s*([^\s]+)/i);
if (encodingMatch) {
result.encoding = encodingMatch[1].trim().toLowerCase();
}
return result;
}
/**
* Decode an email body based on its headers
* @param {string} emailRaw - The raw email content (headers + body)
* @returns {Object} - Object containing decoded text and html parts
*/
export function decodeEmail(emailRaw: string): {
contentType: string;
charset: string;
encoding: string;
decodedBody: string;
headers: string;
} {
// Separate headers and body
const parts = emailRaw.split(/\r?\n\r?\n/);
const headers = parts[0];
const body = parts.slice(1).join('\n\n');
// Parse headers
const { contentType, encoding, charset } = parseEmailHeaders(headers);
// Decode the body
const decodedBody = decodeMIME(body, encoding, charset);
return {
contentType,
charset,
encoding,
decodedBody,
headers
};
}
interface EmailContent {
text: string;
html: string;
attachments: Array<{
contentType: string;
content: string;
filename?: string;
}>;
}
/**
* Process a multipart email to extract text and HTML parts
* @param {string} emailRaw - The raw email content
* @param {string} boundary - The multipart boundary
* @returns {Object} - Object containing text and html parts
*/
export function processMultipartEmail(emailRaw: string, boundary: string): EmailContent {
const result: EmailContent = {
text: '',
html: '',
attachments: []
};
// Split by boundary
const boundaryRegex = new RegExp(`--${boundary}\\r?\\n|--${boundary}--\\r?\\n?`, 'g');
const parts = emailRaw.split(boundaryRegex).filter(part => part.trim());
// Process each part
parts.forEach(part => {
const decoded = decodeEmail(part);
if (decoded.contentType === 'text/plain') {
result.text = decoded.decodedBody;
} else if (decoded.contentType === 'text/html') {
result.html = decoded.decodedBody;
} else if (decoded.contentType.startsWith('image/') ||
decoded.contentType.startsWith('application/')) {
// Extract filename if available
const filenameMatch = decoded.headers.match(/filename=["']?([^"';\r\n]+)/i);
const filename = filenameMatch ? filenameMatch[1] : 'attachment';
// Handle attachments
result.attachments.push({
contentType: decoded.contentType,
content: decoded.decodedBody,
filename
});
}
});
return result;
}
/**
* Extract boundary from Content-Type header
* @param {string} contentType - The Content-Type header value
* @returns {string|null} - The boundary string or null if not found
*/
export function extractBoundary(contentType: string): string | null {
const boundaryMatch = contentType.match(/boundary=["']?([^"';]+)/i);
return boundaryMatch ? boundaryMatch[1] : null;
}
/**
* Parse an email from its raw content
* @param {string} rawEmail - The raw email content
* @returns {Object} - The parsed email with text and html parts
*/
export function parseRawEmail(rawEmail: string): EmailContent {
// Default result structure
const result: EmailContent = {
text: '',
html: '',
attachments: []
};
try {
// Split headers and body
const headerBodySplit = rawEmail.split(/\r?\n\r?\n/);
const headers = headerBodySplit[0];
const body = headerBodySplit.slice(1).join('\n\n');
// Check if multipart
const contentTypeHeader = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (contentTypeHeader && contentTypeHeader[1].includes('multipart/')) {
// Get boundary
const boundary = extractBoundary(contentTypeHeader[1]);
if (boundary) {
// Process multipart email
return processMultipartEmail(rawEmail, boundary);
}
}
// Not multipart, decode as a single part
const { contentType, encoding, charset, decodedBody } = decodeEmail(rawEmail);
// Set content based on type
if (contentType.includes('text/html')) {
result.html = decodedBody;
} else {
result.text = decodedBody;
}
return result;
} catch (error) {
console.error('Error parsing raw email:', error);
// Return raw content as text on error
result.text = rawEmail;
return result;
}
}

72
node_modules/.package-lock.json generated vendored
View File

@ -3584,6 +3584,12 @@
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
@ -4472,6 +4478,18 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@ -4479,6 +4497,13 @@
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -4955,6 +4980,12 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/parse5": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
@ -5576,6 +5607,41 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/quill/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/quoted-printable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
@ -6766,6 +6832,12 @@
"utf8": "^2.1.1"
}
},
"node_modules/vcard-parser": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/vcard-parser/-/vcard-parser-1.0.0.tgz",
"integrity": "sha512-rSEjrjBK3of4VimMR5vBjLLcN5ZCSp9yuVzyx5i4Fwx74Yd0s+DnHtSit/wAAtj1a7/T/qQc0ykwXADoD0+fTQ==",
"license": "MIT"
},
"node_modules/vcd-parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",

74
package-lock.json generated
View File

@ -70,6 +70,7 @@
"next-themes": "^0.4.4",
"nodemailer": "^6.10.1",
"pg": "^8.14.1",
"quill": "^2.0.3",
"react": "^18",
"react-datepicker": "^8.3.0",
"react-day-picker": "8.10.1",
@ -83,6 +84,7 @@
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.6",
"vcard-js": "^1.2.2",
"vcard-parser": "^1.0.0",
"vcd-parser": "^1.0.1",
"webdav": "^5.8.0"
},
@ -4552,6 +4554,12 @@
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
@ -5440,6 +5448,18 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@ -5447,6 +5467,13 @@
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -5923,6 +5950,12 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/parse5": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
@ -6544,6 +6577,41 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/quill/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/quoted-printable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
@ -7734,6 +7802,12 @@
"utf8": "^2.1.1"
}
},
"node_modules/vcard-parser": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/vcard-parser/-/vcard-parser-1.0.0.tgz",
"integrity": "sha512-rSEjrjBK3of4VimMR5vBjLLcN5ZCSp9yuVzyx5i4Fwx74Yd0s+DnHtSit/wAAtj1a7/T/qQc0ykwXADoD0+fTQ==",
"license": "MIT"
},
"node_modules/vcd-parser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",

View File

@ -71,6 +71,8 @@
"next-themes": "^0.4.4",
"nodemailer": "^6.10.1",
"pg": "^8.14.1",
"quill": "^2.0.3",
"quill-better-table": "^1.2.10",
"react": "^18",
"react-datepicker": "^8.3.0",
"react-day-picker": "8.10.1",
@ -84,6 +86,7 @@
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.6",
"vcard-js": "^1.2.2",
"vcard-parser": "^1.0.0",
"vcd-parser": "^1.0.1",
"webdav": "^5.8.0"
},

View File

@ -1835,6 +1835,16 @@ eventemitter3@^4.0.1:
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
eventemitter3@^5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz"
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
fast-diff@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz"
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
fast-equals@^5.0.1:
version "5.2.2"
resolved "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz"
@ -2355,11 +2365,26 @@ linkify-it@5.0.0:
dependencies:
uc.micro "^2.0.0"
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz"
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz"
integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz"
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
@ -2647,6 +2672,11 @@ package-json-from-dist@^1.0.0:
resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz"
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
parchment@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz"
integrity sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==
parse5@^7.0.0, parse5@^7.2.1:
version "7.2.1"
resolved "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz"
@ -3001,6 +3031,25 @@ quick-format-unescaped@^4.0.3:
resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz"
integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==
quill-delta@^5.1.0:
version "5.1.0"
resolved "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz"
integrity sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==
dependencies:
fast-diff "^1.3.0"
lodash.clonedeep "^4.5.0"
lodash.isequal "^4.5.0"
quill@^2.0.3:
version "2.0.3"
resolved "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz"
integrity sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==
dependencies:
eventemitter3 "^5.0.1"
lodash-es "^4.17.21"
parchment "^3.0.0"
quill-delta "^5.1.0"
quoted-printable@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz"
@ -3693,6 +3742,11 @@ vcard-js@^1.2.2:
quoted-printable "^1.0.0"
utf8 "^2.1.1"
vcard-parser@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/vcard-parser/-/vcard-parser-1.0.0.tgz"
integrity sha512-rSEjrjBK3of4VimMR5vBjLLcN5ZCSp9yuVzyx5i4Fwx74Yd0s+DnHtSit/wAAtj1a7/T/qQc0ykwXADoD0+fTQ==
vcd-parser@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz"