Compare commits
No commits in common. "cleanup/email-redundancy" and "main" have entirely different histories.
cleanup/em
...
main
@ -1,144 +0,0 @@
|
|||||||
# 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
82
README.md
@ -1,82 +0,0 @@
|
|||||||
# 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.
|
|
||||||
22
app/api/mail/route.ts
Normal file
22
app/api/mail/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
35
app/api/mail/send/route.ts
Normal file
35
app/api/mail/send/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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 { NextRequest, NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { parseEmail } from '@/lib/server/email-parser';
|
import { simpleParser, AddressObject } from 'mailparser';
|
||||||
import { sanitizeHtml } from '@/lib/utils/email-formatter';
|
|
||||||
|
|
||||||
interface EmailAddress {
|
function getEmailAddress(address: AddressObject | AddressObject[] | undefined): string | null {
|
||||||
name?: string;
|
if (!address) return null;
|
||||||
address: string;
|
if (Array.isArray(address)) {
|
||||||
|
return address.map(a => a.text).join(', ');
|
||||||
|
}
|
||||||
|
return address.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract email addresses from mailparser Address objects
|
// Clean up the HTML to make it safe but preserve styles
|
||||||
function getEmailAddresses(addresses: any): EmailAddress[] {
|
function processHtml(html: string | null): string | null {
|
||||||
if (!addresses) return [];
|
if (!html) return null;
|
||||||
|
|
||||||
// Handle various address formats
|
try {
|
||||||
if (Array.isArray(addresses)) {
|
// Make the content display well in the email context
|
||||||
return addresses.map(addr => ({
|
return html
|
||||||
name: addr.name || undefined,
|
// Fix self-closing tags that might break React
|
||||||
address: addr.address
|
.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
|
||||||
if (typeof addresses === 'object') {
|
return match;
|
||||||
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;
|
} catch (error) {
|
||||||
}
|
console.error('Error processing HTML:', error);
|
||||||
|
return html;
|
||||||
// Handle direct object with address property
|
|
||||||
if (addresses.address) {
|
|
||||||
return [{
|
|
||||||
name: addresses.name || undefined,
|
|
||||||
address: addresses.address
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
const { email } = body;
|
||||||
|
|
||||||
if (!body.email) {
|
if (!email || typeof email !== 'string') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing email content' },
|
{ error: 'Invalid email content' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedEmail = await parseEmail(body.email);
|
const parsed = await simpleParser(email);
|
||||||
|
|
||||||
// Process HTML content if available
|
// Process the HTML to preserve styling but make it safe
|
||||||
if (parsedEmail.html) {
|
// Handle the case where parsed.html could be a boolean
|
||||||
parsedEmail.html = sanitizeHtml(parsedEmail.html);
|
const processedHtml = typeof parsed.html === 'string' ? processHtml(parsed.html) : null;
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(parsedEmail);
|
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 || {}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing email:', error);
|
console.error('Error parsing email:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to parse email', details: error instanceof Error ? error.message : String(error) },
|
{ error: 'Failed to parse email' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* This is a debugging component that provides troubleshooting tools
|
* This is a debugging component that provides troubleshooting tools
|
||||||
* for the email loading process in the Courrier application.
|
* for the email loading process in the Courrier application.
|
||||||
*
|
|
||||||
* NOTE: This component should only be used during development for debugging purposes.
|
|
||||||
* It's kept in the codebase for future reference but won't render in production.
|
|
||||||
*/
|
*/
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
@ -29,11 +26,6 @@ export function LoadingFix({
|
|||||||
loadEmails,
|
loadEmails,
|
||||||
emails
|
emails
|
||||||
}: LoadingFixProps) {
|
}: LoadingFixProps) {
|
||||||
// Don't render anything in production mode
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const forceResetLoadingStates = () => {
|
const forceResetLoadingStates = () => {
|
||||||
console.log('[DEBUG] Force resetting loading states to false');
|
console.log('[DEBUG] Force resetting loading states to false');
|
||||||
// Force both loading states to false
|
// Force both loading states to false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
250
app/globals.css
250
app/globals.css
@ -74,241 +74,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Email display styles */
|
/* Email specific styles */
|
||||||
.email-content-display {
|
.email-content table { width: 100%; border-collapse: collapse; }
|
||||||
max-width: 100%;
|
.email-content table.table-container { width: auto; margin-bottom: 20px; }
|
||||||
word-wrap: break-word;
|
.email-content td, .email-content th { padding: 8px; border: 1px solid #e5e7eb; }
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
.email-content img { max-width: 100%; height: auto; }
|
||||||
line-height: 1.5;
|
.email-content div[style] { max-width: 100% !important; }
|
||||||
font-size: 14px;
|
.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; }
|
||||||
/* Preserve email structure */
|
.email-content a { color: #3b82f6; text-decoration: underline; }
|
||||||
.email-content-display * {
|
.email-content p { margin-bottom: 0.75em; }
|
||||||
max-width: 100% !important;
|
.email-content .header { margin-bottom: 1em; }
|
||||||
}
|
.email-content .footer { font-size: 0.875rem; color: #6b7280; margin-top: 1em; }
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
587
components/ComposeEmail.tsx
Normal file
587
components/ComposeEmail.tsx
Normal file
@ -0,0 +1,587 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,54 +0,0 @@
|
|||||||
'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
@ -1,53 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
'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;
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
'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,43 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { EmailMessage } from '@/lib/services/email-service';
|
||||||
import EmailPreview from './EmailPreview';
|
import EmailPreview from './EmailPreview';
|
||||||
import ComposeEmail from './ComposeEmail';
|
import ComposeEmail from './ComposeEmail';
|
||||||
import { Loader2 } from 'lucide-react';
|
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 {
|
interface EmailPanelProps {
|
||||||
selectedEmailId: string | null;
|
selectedEmailId: string | null;
|
||||||
@ -69,50 +36,6 @@ export default function EmailPanel({
|
|||||||
const [isComposing, setIsComposing] = useState<boolean>(false);
|
const [isComposing, setIsComposing] = useState<boolean>(false);
|
||||||
const [composeType, setComposeType] = useState<'new' | 'reply' | 'reply-all' | 'forward'>('new');
|
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
|
// Load email content when selectedEmailId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEmailId) {
|
if (selectedEmailId) {
|
||||||
@ -244,12 +167,10 @@ export default function EmailPanel({
|
|||||||
onSend={onSendEmail}
|
onSend={onSendEmail}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-4xl mx-auto h-full">
|
<EmailPreview
|
||||||
<EmailPreview
|
email={email}
|
||||||
email={formattedEmail}
|
onReply={handleReply}
|
||||||
onReply={handleReply}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,62 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Loader2, Paperclip, User } from 'lucide-react';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
import { EmailMessage } from '@/lib/services/email-service';
|
||||||
|
import { Loader2, Paperclip, Download } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { cleanHtml } from '@/lib/mail-parser-wrapper';
|
||||||
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 {
|
interface EmailPreviewProps {
|
||||||
email: EmailMessage | null;
|
email: EmailMessage | null;
|
||||||
@ -65,8 +15,38 @@ interface EmailPreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailPreview({ email, loading = false, onReply }: EmailPreviewProps) {
|
export default function EmailPreview({ email, loading = false, onReply }: EmailPreviewProps) {
|
||||||
// Add editorRef to match ComposeEmail exactly
|
const [contentLoading, setContentLoading] = useState<boolean>(false);
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
// 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>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Format the date
|
// Format the date
|
||||||
const formatDate = (date: Date | string) => {
|
const formatDate = (date: Date | string) => {
|
||||||
@ -94,64 +74,17 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
|
|||||||
).join(', ');
|
).join(', ');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get sender initials for avatar
|
if (loading || contentLoading) {
|
||||||
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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-full p-6">
|
<div className="flex items-center justify-center h-full p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
|
||||||
<p>Loading email...</p>
|
<p>Loading email content...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No email selected
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full p-6">
|
<div className="flex items-center justify-center h-full p-6">
|
||||||
@ -162,74 +95,70 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sender = email.from && email.from.length > 0 ? email.from[0] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col h-full overflow-hidden border-0 shadow-none">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* Email header */}
|
{/* Email header */}
|
||||||
<div className="p-6 border-b">
|
<div className="p-4 border-b">
|
||||||
<div className="mb-4">
|
<div className="mb-3">
|
||||||
<h2 className="text-xl font-semibold mb-4">{email.subject}</h2>
|
<h2 className="text-xl font-semibold mb-2">{email.subject}</h2>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="flex items-start gap-3 mb-4">
|
<div className="flex items-center">
|
||||||
<Avatar className="h-10 w-10">
|
<span className="font-medium mr-1">From:</span>
|
||||||
<AvatarFallback>{getSenderInitials(sender?.name || '')}</AvatarFallback>
|
<span>{formatEmailAddresses(email.from)}</span>
|
||||||
</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>
|
</div>
|
||||||
|
<span className="text-muted-foreground">{formatDate(email.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{email.to && email.to.length > 0 && (
|
||||||
{onReply && (
|
<div className="text-sm mt-1">
|
||||||
<div className="flex gap-2 mt-4">
|
<span className="font-medium mr-1">To:</span>
|
||||||
<Button
|
<span>{formatEmailAddresses(email.to)}</span>
|
||||||
size="sm"
|
</div>
|
||||||
variant="outline"
|
)}
|
||||||
onClick={() => onReply('reply')}
|
|
||||||
>
|
{email.cc && email.cc.length > 0 && (
|
||||||
Reply
|
<div className="text-sm mt-1">
|
||||||
</Button>
|
<span className="font-medium mr-1">Cc:</span>
|
||||||
<Button
|
<span>{formatEmailAddresses(email.cc)}</span>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onReply('reply-all')}
|
|
||||||
>
|
|
||||||
Reply All
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onReply('forward')}
|
|
||||||
>
|
|
||||||
Forward
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 */}
|
{/* Attachments */}
|
||||||
{email.attachments && email.attachments.length > 0 && (
|
{email.attachments && email.attachments.length > 0 && (
|
||||||
<div className="px-6 py-3 border-b bg-muted/30">
|
<div className="mt-4 border-t pt-2">
|
||||||
<div className="text-sm font-medium mb-2">Attachments</div>
|
<div className="text-sm font-medium mb-2">Attachments:</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{email.attachments.map((attachment, index) => (
|
{email.attachments.map((attachment, index) => (
|
||||||
<Badge key={index} variant="outline" className="flex items-center gap-1 px-2 py-1">
|
<Badge key={index} variant="outline" className="flex items-center gap-1">
|
||||||
<Paperclip className="h-3.5 w-3.5" />
|
<Paperclip className="h-3 w-3" />
|
||||||
<span>{attachment.filename}</span>
|
<span>{attachment.filename}</span>
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
({Math.round(attachment.size / 1024)}KB)
|
({Math.round(attachment.size / 1024)}KB)
|
||||||
@ -241,19 +170,10 @@ export default function EmailPreview({ email, loading = false, onReply }: EmailP
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email content - using the preformatted content */}
|
{/* Email content */}
|
||||||
<ScrollArea className="flex-1">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<div className="space-y-2 p-6">
|
{renderContent()}
|
||||||
<div className="border rounded-md overflow-hidden">
|
</div>
|
||||||
<div
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,142 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
'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;
|
|
||||||
@ -1,417 +0,0 @@
|
|||||||
'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;
|
|
||||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@ -1,2 +0,0 @@
|
|||||||
// Global type declarations
|
|
||||||
declare module 'quill-better-table';
|
|
||||||
@ -1,466 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
'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'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
60
lib/compose-mime-decoder.ts
Normal file
60
lib/compose-mime-decoder.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
93
lib/email-parser.ts
Normal file
93
lib/email-parser.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
123
lib/mail-parser-wrapper.ts
Normal file
123
lib/mail-parser-wrapper.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
'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,22 +1,30 @@
|
|||||||
import { sanitizeHtml } from '@/lib/utils/email-formatter';
|
|
||||||
import { simpleParser } from 'mailparser';
|
import { simpleParser } from 'mailparser';
|
||||||
|
|
||||||
function getAddressText(addresses: any): string | null {
|
export function cleanHtml(html: string): string {
|
||||||
if (!addresses) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(addresses)) {
|
// More permissive cleaning that preserves styling but removes potentially harmful elements
|
||||||
return addresses.map(a => a.address || '').join(', ');
|
return html
|
||||||
} else if (typeof addresses === 'object') {
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
return addresses.address || null;
|
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
||||||
}
|
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
|
||||||
return null;
|
.replace(/<embed\b[^<]*(?:(?!<\/embed>)<[^<]*)*<\/embed>/gi, '')
|
||||||
|
.replace(/<form\b[^<]*(?:(?!<\/form>)<[^<]*)*<\/form>/gi, '')
|
||||||
|
.replace(/on\w+="[^"]*"/gi, '') // Remove inline event handlers (onclick, onload, etc.)
|
||||||
|
.replace(/on\w+='[^']*'/gi, '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error formatting addresses:', error);
|
console.error('Error cleaning HTML:', error);
|
||||||
return null;
|
return html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export async function parseEmail(emailContent: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(emailContent);
|
const parsed = await simpleParser(emailContent);
|
||||||
@ -28,7 +36,7 @@ export async function parseEmail(emailContent: string) {
|
|||||||
cc: getAddressText(parsed.cc),
|
cc: getAddressText(parsed.cc),
|
||||||
bcc: getAddressText(parsed.bcc),
|
bcc: getAddressText(parsed.bcc),
|
||||||
date: parsed.date || null,
|
date: parsed.date || null,
|
||||||
html: parsed.html ? sanitizeHtml(parsed.html as string) : null,
|
html: parsed.html ? cleanHtml(parsed.html) : null,
|
||||||
text: parsed.text || null,
|
text: parsed.text || null,
|
||||||
attachments: parsed.attachments || [],
|
attachments: parsed.attachments || [],
|
||||||
headers: Object.fromEntries(parsed.headers)
|
headers: Object.fromEntries(parsed.headers)
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import 'server-only';
|
|
||||||
import { ImapFlow } from 'imapflow';
|
import { ImapFlow } from 'imapflow';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
@ -628,5 +625,134 @@ export async function testEmailConnection(credentials: EmailCredentials): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email formatting functions have been moved to lib/utils/email-formatter.ts
|
/**
|
||||||
// Use those functions instead of the ones previously defined here
|
* 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>`;
|
||||||
|
}
|
||||||
@ -1,447 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}`;
|
|
||||||
}
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,12 +3584,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
"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": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||||
@ -4478,18 +4472,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
"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": {
|
"node_modules/lodash.get": {
|
||||||
"version": "4.4.2",
|
"version": "4.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||||
@ -4497,13 +4479,6 @@
|
|||||||
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@ -4980,12 +4955,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
"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": {
|
"node_modules/parse5": {
|
||||||
"version": "7.2.1",
|
"version": "7.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
|
||||||
@ -5607,41 +5576,6 @@
|
|||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/quoted-printable": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
|
||||||
@ -6832,12 +6766,6 @@
|
|||||||
"utf8": "^2.1.1"
|
"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": {
|
"node_modules/vcd-parser": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
||||||
|
|||||||
74
package-lock.json
generated
74
package-lock.json
generated
@ -70,7 +70,6 @@
|
|||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"quill": "^2.0.3",
|
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-datepicker": "^8.3.0",
|
"react-datepicker": "^8.3.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
@ -84,7 +83,6 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.6",
|
"vaul": "^0.9.6",
|
||||||
"vcard-js": "^1.2.2",
|
"vcard-js": "^1.2.2",
|
||||||
"vcard-parser": "^1.0.0",
|
|
||||||
"vcd-parser": "^1.0.1",
|
"vcd-parser": "^1.0.1",
|
||||||
"webdav": "^5.8.0"
|
"webdav": "^5.8.0"
|
||||||
},
|
},
|
||||||
@ -4554,12 +4552,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
"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": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||||
@ -5448,18 +5440,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
"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": {
|
"node_modules/lodash.get": {
|
||||||
"version": "4.4.2",
|
"version": "4.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||||
@ -5467,13 +5447,6 @@
|
|||||||
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@ -5950,12 +5923,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
"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": {
|
"node_modules/parse5": {
|
||||||
"version": "7.2.1",
|
"version": "7.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
|
||||||
@ -6577,41 +6544,6 @@
|
|||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/quoted-printable": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
|
||||||
@ -7802,12 +7734,6 @@
|
|||||||
"utf8": "^2.1.1"
|
"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": {
|
"node_modules/vcd-parser": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz",
|
||||||
|
|||||||
@ -71,8 +71,6 @@
|
|||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"quill": "^2.0.3",
|
|
||||||
"quill-better-table": "^1.2.10",
|
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-datepicker": "^8.3.0",
|
"react-datepicker": "^8.3.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
@ -86,7 +84,6 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.6",
|
"vaul": "^0.9.6",
|
||||||
"vcard-js": "^1.2.2",
|
"vcard-js": "^1.2.2",
|
||||||
"vcard-parser": "^1.0.0",
|
|
||||||
"vcd-parser": "^1.0.1",
|
"vcd-parser": "^1.0.1",
|
||||||
"webdav": "^5.8.0"
|
"webdav": "^5.8.0"
|
||||||
},
|
},
|
||||||
|
|||||||
54
yarn.lock
54
yarn.lock
@ -1835,16 +1835,6 @@ eventemitter3@^4.0.1:
|
|||||||
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz"
|
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz"
|
||||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
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:
|
fast-equals@^5.0.1:
|
||||||
version "5.2.2"
|
version "5.2.2"
|
||||||
resolved "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz"
|
resolved "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz"
|
||||||
@ -2365,26 +2355,11 @@ linkify-it@5.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
uc.micro "^2.0.0"
|
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:
|
lodash.get@^4.4.2:
|
||||||
version "4.4.2"
|
version "4.4.2"
|
||||||
resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz"
|
resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz"
|
||||||
integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
|
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:
|
lodash@^4.17.21:
|
||||||
version "4.17.21"
|
version "4.17.21"
|
||||||
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
||||||
@ -2672,11 +2647,6 @@ package-json-from-dist@^1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz"
|
resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz"
|
||||||
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
|
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:
|
parse5@^7.0.0, parse5@^7.2.1:
|
||||||
version "7.2.1"
|
version "7.2.1"
|
||||||
resolved "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz"
|
resolved "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz"
|
||||||
@ -3031,25 +3001,6 @@ quick-format-unescaped@^4.0.3:
|
|||||||
resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz"
|
resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz"
|
||||||
integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==
|
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:
|
quoted-printable@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz"
|
resolved "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz"
|
||||||
@ -3742,11 +3693,6 @@ vcard-js@^1.2.2:
|
|||||||
quoted-printable "^1.0.0"
|
quoted-printable "^1.0.0"
|
||||||
utf8 "^2.1.1"
|
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:
|
vcd-parser@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz"
|
resolved "https://registry.npmjs.org/vcd-parser/-/vcd-parser-1.0.1.tgz"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user