Compare commits
115 Commits
main
...
cleanup/em
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cfccc55fc | |||
| 7466037779 | |||
| c3284982b5 | |||
| 021df6b169 | |||
| c92f593caa | |||
| de728b9139 | |||
| 46d8220466 | |||
| 45bbb8229f | |||
| 80e5b3fdcf | |||
| 2ba6a2717b | |||
| 819955d24b | |||
| 80d631eee0 | |||
| ec3b498233 | |||
| a1241a20fa | |||
| 5ec5ad58df | |||
| 9f82be44cc | |||
| c976b23a6c | |||
| e023d050d2 | |||
| 4ef9268b86 | |||
| 51a92f27dd | |||
| 02c9e7054d | |||
| 19cff1ce7c | |||
| a51a4c303d | |||
| b036530766 | |||
| 76cc7b5bf2 | |||
| 1c4b38ec8f | |||
| c993fe738e | |||
| 3c738f179a | |||
| 1d7f0b2b69 | |||
| bb97f9b364 | |||
| cb3e119a5d | |||
| 88e03326af | |||
| 034cf6da23 | |||
| bf7b02b903 | |||
| 4ee8eb6662 | |||
| 685d8b4a2f | |||
| 9970c7b4c9 | |||
| 3c4151335b | |||
| a2a088cf0f | |||
| c7f5e31b23 | |||
| 3992718204 | |||
| ae1087f401 | |||
| c44ce9d41e | |||
| 7fa72b5489 | |||
| 87695eab03 | |||
| 2beb44712c | |||
| 7139d52100 | |||
| 9befdd60c3 | |||
| 4af36d63f9 | |||
| a30198cb2b | |||
| dcc2594195 | |||
| b056438814 | |||
| 367b79bf0b | |||
| aefe858106 | |||
| 6e66c2ac34 | |||
| d09d8f0579 | |||
| e95f078bbc | |||
| 88020ccfe5 | |||
| 96cf29b98b | |||
| 1add54f457 | |||
| b14b03877c | |||
| bc5809520d | |||
| 773c7759c4 | |||
| fb0ab72675 | |||
| 1685946c07 | |||
| e40df0ad87 | |||
| 91751b23ca | |||
| ccf129f1a4 | |||
| 4bf9fcc165 | |||
| ad9999fdd5 | |||
| 26e72f4f73 | |||
| 5686b9fb7d | |||
| 0bb4fac9f9 | |||
| 44f3b3072f | |||
| 104c0d9f94 | |||
| d7bbb68b5e | |||
| aeaa086b40 | |||
| 1663721c89 | |||
| afc7f64027 | |||
| 4c9fcdeb29 | |||
| 3e6b8cae1f | |||
| e3b946f7e9 | |||
| ddf72cc4f0 | |||
| 8137b42c5f | |||
| c0153a2aef | |||
| d607cc54bb | |||
| 24bafdcce4 | |||
| 051bcb08a4 | |||
| 97fb21a632 | |||
| 626b35bb40 | |||
| 090e703214 | |||
| 71cc875f57 | |||
| 34d2aed721 | |||
| 9fba1ac1c3 | |||
| f4112f3160 | |||
| 6f38d53335 | |||
| 59f9afe9fe | |||
| 0c5437a24f | |||
| e3791fa583 | |||
| b0387982bf | |||
| ad1723cce9 | |||
| a9dc2da891 | |||
| 3f5e3097d3 | |||
| 2c98417f50 | |||
| 10b08b5043 | |||
| f4a77ecd25 | |||
| 4db3140ece | |||
| c325d3cdf7 | |||
| 195f8b7115 | |||
| 6bacfa28da | |||
| 638a2ee895 | |||
| f117d66626 | |||
| 7820663c78 | |||
| e3db0a2ae1 | |||
| b58539aeaa |
144
DEPRECATED_FUNCTIONS.md
Normal file
144
DEPRECATED_FUNCTIONS.md
Normal 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
82
README.md
Normal 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.
|
||||
@ -1,22 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* This route is deprecated. It redirects to the new courrier API endpoint.
|
||||
* @deprecated Use the /api/courrier endpoint instead
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
console.warn('Deprecated: /api/mail route is being used. Update your code to use /api/courrier instead.');
|
||||
|
||||
// Extract query parameters
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Redirect to the new API endpoint
|
||||
const redirectUrl = new URL('/api/courrier', url.origin);
|
||||
|
||||
// Copy all search parameters
|
||||
url.searchParams.forEach((value, key) => {
|
||||
redirectUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString());
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* This route is deprecated. It redirects to the new courrier API endpoint.
|
||||
* @deprecated Use the /api/courrier/send endpoint instead
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
console.warn('Deprecated: /api/mail/send route is being used. Update your code to use /api/courrier/send instead.');
|
||||
|
||||
try {
|
||||
// Clone the request body
|
||||
const body = await request.json();
|
||||
|
||||
// Make a new request to the courrier API
|
||||
const newRequest = new Request(new URL('/api/courrier/send', request.url).toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Forward the request
|
||||
const response = await fetch(newRequest);
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error('Error forwarding to courrier/send:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send email' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
250
app/globals.css
250
app/globals.css
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
54
components/email/BulkActionsToolbar.tsx
Normal file
54
components/email/BulkActionsToolbar.tsx
Normal 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
53
components/email/ComposeEmailFooter.tsx
Normal file
53
components/email/ComposeEmailFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
components/email/ComposeEmailForm.tsx
Normal file
227
components/email/ComposeEmailForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
components/email/ComposeEmailHeader.tsx
Normal file
41
components/email/ComposeEmailHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
components/email/EmailContent.tsx
Normal file
135
components/email/EmailContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
components/email/EmailContentDisplay.tsx
Normal file
197
components/email/EmailContentDisplay.tsx
Normal 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;
|
||||
163
components/email/EmailDetailView.tsx
Normal file
163
components/email/EmailDetailView.tsx
Normal 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"><{email.from?.[0]?.address || ''}></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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
components/email/EmailDialogs.tsx
Normal file
74
components/email/EmailDialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
components/email/EmailHeader.tsx
Normal file
119
components/email/EmailHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
components/email/EmailList.tsx
Normal file
177
components/email/EmailList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
components/email/EmailListHeader.tsx
Normal file
43
components/email/EmailListHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
components/email/EmailListItem.tsx
Normal file
163
components/email/EmailListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
142
components/email/EmailSidebar.tsx
Normal file
142
components/email/EmailSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
components/email/EmailSidebarContent.tsx
Normal file
68
components/email/EmailSidebarContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
components/email/QuotedEmailContent.tsx
Normal file
144
components/email/QuotedEmailContent.tsx
Normal 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} <{sender.email}></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;
|
||||
417
components/email/RichEmailEditor.tsx
Normal file
417
components/email/RichEmailEditor.tsx
Normal 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
0
email_changes.diff
Normal file
2
global.d.ts
vendored
Normal file
2
global.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// Global type declarations
|
||||
declare module 'quill-better-table';
|
||||
466
hooks/use-courrier.ts
Normal file
466
hooks/use-courrier.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
121
lib/actions/email-actions.ts
Normal file
121
lib/actions/email-actions.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
// Clean up whitespace
|
||||
return html.replace(/\n\s*\n/g, '\n\n').trim();
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
447
lib/utils/email-formatter.ts
Normal file
447
lib/utils/email-formatter.ts
Normal 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}`;
|
||||
}
|
||||
275
lib/utils/email-mime-decoder.ts
Normal file
275
lib/utils/email-mime-decoder.ts
Normal 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
72
node_modules/.package-lock.json
generated
vendored
@ -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
74
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
54
yarn.lock
54
yarn.lock
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user