mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-10-13 00:00:57 +00:00
feat: Implement comprehensive i18n support with English and French
- Add next-intl for internationalization - Create English and French translation files - Implement language switcher component in sidebar - Configure automatic browser language detection - Update all components to use translation keys - Set up locale-aware routing with [locale] directory structure - Add i18n guidelines to CLAUDE.md for future development - Store user language preference in localStorage The app now supports: - English (en) and French (fr) languages - Automatic browser language detection on first visit - User language preference persistence - Real-time language switching without page reload - Complete translation coverage for all UI elements
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
- I want you to focus on offer the best UI/UX experience to the user who's using the webmail
|
||||
- We care about security and privacy.
|
||||
- Take care to update @TODO.md to keep track on tasks.
|
||||
- Don't forget to commit when its needed.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses **next-intl** for internationalization with support for English and French.
|
||||
|
||||
### Key Guidelines:
|
||||
|
||||
1. **Always use translations** - Never hardcode user-facing text. Use the `useTranslations` hook:
|
||||
```tsx
|
||||
const t = useTranslations('namespace');
|
||||
return <div>{t('key')}</div>;
|
||||
```
|
||||
|
||||
2. **Translation files location**: `/locales/{locale}/common.json`
|
||||
- English: `/locales/en/common.json`
|
||||
- French: `/locales/fr/common.json`
|
||||
|
||||
3. **Namespace organization**: Group related translations:
|
||||
- `login.*` - Login page strings
|
||||
- `sidebar.*` - Sidebar navigation
|
||||
- `email_list.*` - Email list component
|
||||
- `email_viewer.*` - Email viewer component
|
||||
- `email_composer.*` - Email composer
|
||||
- `common.*` - Shared strings
|
||||
- `notifications.*` - Toast/alert messages
|
||||
- `date.*` - Date-related strings
|
||||
|
||||
4. **Dynamic routing**: All pages must be under `/app/[locale]/` directory
|
||||
|
||||
5. **Locale-aware navigation**: Always include locale in routes:
|
||||
```tsx
|
||||
router.push(`/${params.locale}/login`);
|
||||
```
|
||||
|
||||
6. **Adding new strings**:
|
||||
- Add to both `/locales/en/common.json` and `/locales/fr/common.json`
|
||||
- Use descriptive, hierarchical keys
|
||||
- Keep translations consistent in tone and terminology
|
||||
|
||||
7. **Browser language detection**: Automatically detects and uses browser language on first visit
|
||||
|
||||
8. **User preference**: Language selection is persisted and overrides browser detection
|
||||
@@ -0,0 +1,187 @@
|
||||
# JMAP Webmail - TODO List
|
||||
|
||||
**Mail Server:** Using [Stalw.art](https://stalw.art/) JMAP server
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### Initial Setup
|
||||
- [x] Initialize Next.js 15.5 project with TypeScript
|
||||
- [x] Configure Tailwind CSS v4 with Oxide engine
|
||||
- [x] Set up git repository
|
||||
- [x] Install core dependencies (jmap-jam, zustand, lucide-react)
|
||||
|
||||
### UI Components
|
||||
- [x] Create Button component with variants
|
||||
- [x] Create Input component
|
||||
- [x] Create EmailListItem component with unread/starred indicators
|
||||
- [x] Create EmailList component with avatars
|
||||
- [x] Create EmailViewer component with full HTML support
|
||||
- [x] Create EmailComposer modal component
|
||||
- [x] Create Sidebar with mailbox navigation
|
||||
- [x] Create Avatar component with color hashing
|
||||
|
||||
### Layout & Styling
|
||||
- [x] Implement three-pane layout (sidebar, list, viewer)
|
||||
- [x] Add minimalist design system with gray palette
|
||||
- [x] Configure custom scrollbars
|
||||
- [x] Add responsive design considerations
|
||||
- [x] Fix Tailwind CSS v4 compatibility issues
|
||||
|
||||
### State Management
|
||||
- [x] Set up Zustand store for email state
|
||||
- [x] Create mock data for development
|
||||
- [x] Implement email selection logic
|
||||
- [x] Implement mailbox selection
|
||||
|
||||
### JMAP Integration (Partial)
|
||||
- [x] Create JMAP types (Email, Mailbox, Thread, etc.)
|
||||
- [x] Create JMAP client wrapper class
|
||||
- [x] Define core JMAP methods (getEmails, sendEmail, etc.)
|
||||
|
||||
## 📝 To Do
|
||||
|
||||
### Authentication
|
||||
- [x] Create auth store with Zustand
|
||||
- [x] Create login page with JMAP server URL input
|
||||
- [x] Add session management
|
||||
- [x] Implement logout functionality
|
||||
- [x] Add remember me functionality
|
||||
- [x] Handle authentication errors
|
||||
|
||||
### JMAP Server Connection
|
||||
- [x] Connect to real JMAP server (mail.ma2t.com)
|
||||
- [x] Implement session establishment
|
||||
- [x] Handle connection errors and retries
|
||||
- [x] Fetch and display storage quota
|
||||
- [ ] Add server capability detection
|
||||
- [ ] Implement keep-alive mechanism
|
||||
|
||||
### Email Operations
|
||||
- [x] Wire up real email fetching from JMAP
|
||||
- [x] Implement email sending functionality
|
||||
- [x] Fetch full email content on selection
|
||||
- [x] Display actual dates instead of relative time
|
||||
- [x] Implement reply/reply-all/forward with proper quoting
|
||||
- [x] Add color tags/labels for emails (like Thunderbird)
|
||||
- [x] Implement mark as read/unread
|
||||
- [x] Implement star/unstar emails
|
||||
- [x] Implement delete email
|
||||
- [x] Implement archive email
|
||||
- [x] Implement full-text search
|
||||
- [ ] Add draft auto-save
|
||||
- [x] Add attachment download support
|
||||
- [ ] Add attachment upload support
|
||||
- [ ] Implement batch operations (mark read/unread, delete multiple)
|
||||
- [ ] Add email threading support
|
||||
|
||||
### Real-time Updates
|
||||
- [ ] Set up EventSource for JMAP push notifications
|
||||
- [ ] Implement state synchronization
|
||||
- [ ] Handle email arrival notifications
|
||||
- [ ] Update unread counts in real-time
|
||||
- [ ] Handle mailbox changes
|
||||
|
||||
### UI Enhancements
|
||||
- [x] Improve mailbox subfolder UI/UX with hierarchical display
|
||||
- [x] Improve email viewer UI with better sender display
|
||||
- [x] Add compact attachment display with file type icons
|
||||
- [x] Modernize email list with better visual hierarchy
|
||||
- [x] Widen email list panel for better readability
|
||||
- [x] Redesign email action buttons with modern look
|
||||
- [x] Improve email headers display with expandable details
|
||||
- [x] Add minimalist external content warning banner
|
||||
- [x] Implement color tags with background tint display
|
||||
- [x] Create compact dynamic color picker (hover to reveal)
|
||||
- [x] Redesign Message Details with security & authentication indicators
|
||||
- [x] Add SPF/DKIM/DMARC status display with visual indicators
|
||||
- [x] Create organized sections for technical email details
|
||||
- [x] Parse and display email header information
|
||||
- [ ] Add loading states and skeletons
|
||||
- [ ] Implement error boundaries
|
||||
- [ ] Create settings page
|
||||
- [ ] Add keyboard shortcuts (j/k navigation, etc.)
|
||||
- [ ] Implement drag-and-drop for emails
|
||||
- [ ] Add context menus
|
||||
- [ ] Create mobile-responsive design
|
||||
- [ ] Add dark mode toggle
|
||||
- [ ] Implement pagination/infinite scroll for email list
|
||||
- [x] Add i18n (internationalization) support with English and French
|
||||
|
||||
### Address Book & Contacts
|
||||
- [ ] Create contact store with Zustand
|
||||
- [ ] Implement contact CRUD operations (create, read, update, delete)
|
||||
- [ ] Create contacts list view with search/filter
|
||||
- [ ] Add contact details view/edit form
|
||||
- [ ] Implement contact groups/lists management
|
||||
- [ ] Add vCard import/export support
|
||||
- [ ] Sync contacts with JMAP server (if supported)
|
||||
- [ ] Implement email autocomplete from contacts
|
||||
- [ ] Integrate contacts with email composer
|
||||
- [ ] Add bulk contact operations
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Implement filters and labels
|
||||
- [ ] Add calendar integration (if server supports)
|
||||
- [ ] Create email templates
|
||||
- [ ] Add signature management
|
||||
- [ ] Implement vacation responder settings
|
||||
- [ ] Add email aliases support
|
||||
- [ ] Create advanced search with filters
|
||||
- [ ] Add email encryption support (PGP/GPG)
|
||||
|
||||
### Performance Optimizations
|
||||
- [ ] Implement virtual scrolling for large email lists
|
||||
- [ ] Add email content caching
|
||||
- [ ] Optimize bundle size
|
||||
- [ ] Add service worker for offline support
|
||||
- [ ] Implement lazy loading for attachments
|
||||
- [ ] Add image optimization for email content
|
||||
|
||||
### Testing
|
||||
- [ ] Add unit tests for utilities
|
||||
- [ ] Create component tests
|
||||
- [ ] Add E2E tests with Playwright
|
||||
- [ ] Test JMAP client methods
|
||||
- [ ] Add accessibility testing
|
||||
- [ ] Performance testing
|
||||
|
||||
### Documentation
|
||||
- [ ] Create user documentation
|
||||
- [ ] Add API documentation for JMAP client
|
||||
- [ ] Create deployment guide
|
||||
- [ ] Add configuration documentation
|
||||
- [ ] Create contributing guidelines
|
||||
|
||||
### Deployment
|
||||
- [ ] Create environment variable management
|
||||
- [ ] Add health check endpoint
|
||||
- [ ] Configure production build optimizations
|
||||
- [ ] Set up monitoring and logging
|
||||
|
||||
### Security
|
||||
- [x] Block external images/content by default (privacy protection)
|
||||
- [x] Add user control for loading external content
|
||||
- [x] Sanitize HTML email content with DOMPurify
|
||||
- [ ] Implement CSP headers
|
||||
- [ ] Add additional XSS protection layers
|
||||
- [ ] Implement rate limiting
|
||||
- [ ] Add CORS configuration
|
||||
- [ ] Implement secure cookie handling
|
||||
|
||||
## 🐛 Known Issues
|
||||
- [ ] Fix Next.js workspace root warning
|
||||
- [x] Handle port conflicts more gracefully (using port 3001)
|
||||
- [ ] Improve error messages for failed JMAP operations
|
||||
- [x] Fixed JamClient import issue from jmap-jam library
|
||||
- [x] Fixed multiple JMAP client files issue (cleaned up to single client.ts)
|
||||
- [x] Fixed session persistence on page refresh (using sessionStorage for credentials)
|
||||
- [x] Fixed mailbox ID mismatch (now using actual IDs from server)
|
||||
- [x] Fixed email not displaying (proper mailbox filtering)
|
||||
- [x] Fixed mailbox counters showing fake data (now showing real server data)
|
||||
- [x] Fixed color removal not working (keywords now set to false instead of deleted)
|
||||
- [x] Fixed color indicator showing previous color after removal
|
||||
- [x] Fixed HTML emails showing unwanted borders (selective CSS for data tables)
|
||||
- [x] Fixed email remaining open when switching mailboxes (now clears selection)
|
||||
- [x] Fixed attachment downloads returning 404 (proper URI template handling)
|
||||
- [x] Fixed plain text emails displaying on one line (smart HTML/text version selection)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { locales } from "@/i18n";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: { locale }
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { locale: string };
|
||||
}) {
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!locales.includes(locale as any)) notFound();
|
||||
|
||||
// Load messages for the current locale
|
||||
let messages;
|
||||
try {
|
||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||
} catch (error) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { Mail, Lock, Server, User, AlertCircle } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations("login");
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
serverUrl: "https://mail.ma2t.com",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
}, [isAuthenticated, router, params.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
}, [formData, clearError]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const success = await login(
|
||||
formData.serverUrl,
|
||||
formData.username,
|
||||
formData.password
|
||||
);
|
||||
|
||||
if (success) {
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-md w-full mx-auto">
|
||||
<div className="bg-white dark:bg-gray-950 rounded-lg shadow-sm border dark:border-gray-800 p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<Mail className="w-8 h-8 text-gray-700 dark:text-gray-300" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">{t("title")}</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-md flex items-start space-x-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-800 dark:text-red-200">
|
||||
{error && (t(`error.${error}`) || t("error.generic"))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="server" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("server_label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Server className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
id="server"
|
||||
type="url"
|
||||
value={formData.serverUrl}
|
||||
onChange={(e) => setFormData({ ...formData, serverUrl: e.target.value })}
|
||||
className="pl-9"
|
||||
placeholder={t("server_placeholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("username_label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="pl-9"
|
||||
placeholder={t("username_placeholder")}
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("password_label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="pl-9"
|
||||
placeholder={t("password_placeholder")}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t("signing_in") : t("sign_in")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("secure_connection")}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||
{t("security_note")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations('common');
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const {
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedEmail,
|
||||
selectedMailbox,
|
||||
quota,
|
||||
selectEmail,
|
||||
selectMailbox,
|
||||
fetchMailboxes,
|
||||
fetchEmails,
|
||||
fetchQuota,
|
||||
sendEmail,
|
||||
deleteEmail,
|
||||
markAsRead,
|
||||
toggleStar,
|
||||
moveToMailbox,
|
||||
searchEmails,
|
||||
isLoading,
|
||||
} = useEmailStore();
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
router.push(`/${params.locale}/login`);
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router, params.locale]);
|
||||
|
||||
// Load mailboxes and emails when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && !dataLoaded) {
|
||||
const loadData = async () => {
|
||||
console.log('Loading email data...');
|
||||
try {
|
||||
// First fetch mailboxes and quota
|
||||
await Promise.all([
|
||||
fetchMailboxes(client),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
// Get the actual inbox mailbox ID
|
||||
const state = useEmailStore.getState();
|
||||
const inboxMailbox = state.mailboxes.find(m => m.role === 'inbox');
|
||||
|
||||
if (inboxMailbox) {
|
||||
console.log('Found inbox mailbox:', inboxMailbox.id, inboxMailbox.name);
|
||||
// Update selected mailbox to actual inbox ID
|
||||
state.selectMailbox(inboxMailbox.id);
|
||||
// Now fetch emails with correct mailbox ID
|
||||
await fetchEmails(client, inboxMailbox.id);
|
||||
} else {
|
||||
console.log('No inbox found, fetching all emails');
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
setDataLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
}, [isAuthenticated, client, dataLoaded, fetchMailboxes, fetchEmails, fetchQuota]);
|
||||
|
||||
const handleEmailSend = async (data: {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc);
|
||||
setShowComposer(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleReplyAll = () => {
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
try {
|
||||
await deleteEmail(client, selectedEmail.id);
|
||||
selectEmail(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
// Find archive mailbox
|
||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
if (archiveMailbox) {
|
||||
try {
|
||||
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
||||
selectEmail(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive email:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
try {
|
||||
await toggleStar(client, selectedEmail.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle star:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
// Remove any existing color tags
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
// Remove old color tags - set to false for JMAP to remove them
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$color:")) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Add new color tag if specified
|
||||
if (color) {
|
||||
keywords[`$color:${color}`] = true;
|
||||
}
|
||||
|
||||
// Update email keywords via JMAP
|
||||
await client.updateEmailKeywords(emailId, keywords);
|
||||
|
||||
// Update local state
|
||||
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
|
||||
|
||||
// Refresh emails list to show color in list
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
} catch (error) {
|
||||
console.error("Failed to set color tag:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMailboxSelect = async (mailboxId: string) => {
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null); // Clear selected email when switching mailboxes
|
||||
if (client) {
|
||||
await fetchEmails(client, mailboxId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push(`/${params.locale}/login`);
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
await searchEmails(client, query);
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state while checking auth or loading data
|
||||
if (!initialCheckDone || authLoading || (!isAuthenticated || !client || isLoading)) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100 mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
onSearch={handleSearch}
|
||||
quota={quota}
|
||||
/>
|
||||
|
||||
{/* Email List */}
|
||||
<div className="w-96 bg-white dark:bg-gray-950 border-r dark:border-gray-800 flex-shrink-0 shadow-sm">
|
||||
<EmailList
|
||||
emails={emails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
onEmailSelect={async (email) => {
|
||||
if (!client || !email) return;
|
||||
|
||||
// First, select the email to show it immediately
|
||||
selectEmail(email);
|
||||
|
||||
// Then fetch the full content
|
||||
try {
|
||||
const fullEmail = await client.getEmail(email.id);
|
||||
if (fullEmail) {
|
||||
selectEmail(fullEmail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch email content:', error);
|
||||
}
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Viewer */}
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
}
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
/>
|
||||
|
||||
{/* Email Composer Modal */}
|
||||
{showComposer && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="w-full max-w-3xl h-[600px] mx-4">
|
||||
<EmailComposer
|
||||
mode={composerMode}
|
||||
replyTo={selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
receivedAt: selectedEmail.receivedAt
|
||||
} : undefined}
|
||||
onSend={handleEmailSend}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+204
-22
@@ -1,19 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-border: #e5e5e5;
|
||||
--color-input: #e5e5e5;
|
||||
--color-ring: #a3a3a3;
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #94a3b8;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0a0a0a;
|
||||
--color-primary: #171717;
|
||||
--color-primary-foreground: #fafafa;
|
||||
--color-secondary: #f5f5f5;
|
||||
--color-secondary-foreground: #171717;
|
||||
--color-muted: #f5f5f5;
|
||||
--color-muted-foreground: #737373;
|
||||
--color-accent: #f5f5f5;
|
||||
--color-accent-foreground: #171717;
|
||||
--color-foreground: #0f172a;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #f8fafc;
|
||||
--color-secondary-foreground: #0f172a;
|
||||
--color-muted: #f1f5f9;
|
||||
--color-muted-foreground: #64748b;
|
||||
--color-accent: #dbeafe;
|
||||
--color-accent-foreground: #1e40af;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -78,23 +78,205 @@ body {
|
||||
background-color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Utility classes for email content */
|
||||
/* Enhanced Email Content Styling */
|
||||
.email-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.email-content pre {
|
||||
background-color: var(--color-muted);
|
||||
.email-content p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.email-content a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.email-content a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-accent-foreground);
|
||||
}
|
||||
|
||||
.email-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.email-content blockquote {
|
||||
border-left: 2px solid var(--color-border);
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin: 1rem 0;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .email-content blockquote {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.email-content pre {
|
||||
background-color: #f5f5f7;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content pre {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.email-content code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.dark .email-content code {
|
||||
background-color: #374151;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.email-content h1,
|
||||
.email-content h2,
|
||||
.email-content h3,
|
||||
.email-content h4 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.email-content h1 { font-size: 1.5rem; }
|
||||
.email-content h2 { font-size: 1.25rem; }
|
||||
.email-content h3 { font-size: 1.125rem; }
|
||||
.email-content h4 { font-size: 1rem; }
|
||||
|
||||
.email-content ul,
|
||||
.email-content ol {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.email-content li {
|
||||
margin: 0.375rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.email-content ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.email-content ol li {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Only style tables that are actual data tables, not layout tables */
|
||||
.email-content table.data-table,
|
||||
.email-content table[border="1"] {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.email-content table.data-table th,
|
||||
.email-content table.data-table td,
|
||||
.email-content table[border="1"] th,
|
||||
.email-content table[border="1"] td {
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.email-content table.data-table th,
|
||||
.email-content table[border="1"] th {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table th,
|
||||
.dark .email-content table[border="1"] th {
|
||||
background-color: #1f2937;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table td,
|
||||
.dark .email-content table[border="1"] td {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
/* Reset styles for layout tables (commonly used in HTML emails) */
|
||||
.email-content table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* Let HTML email's own styles take precedence */
|
||||
.email-content table:not(.data-table):not([border="1"]) {
|
||||
border: initial;
|
||||
}
|
||||
|
||||
.email-content table:not(.data-table):not([border="1"]) td,
|
||||
.email-content table:not(.data-table):not([border="1"]) th {
|
||||
border: initial;
|
||||
padding: initial;
|
||||
}
|
||||
|
||||
.email-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content hr {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
/* Email thread quoted text */
|
||||
.email-content .quoted-text {
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #6b7280;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dark .email-content .quoted-text {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
+8
-31
@@ -1,34 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
// This is the root layout that wraps all pages
|
||||
// The actual layout with providers and styles is in [locale]/layout.tsx
|
||||
export default function RootLayout({ children }: Props) {
|
||||
return children;
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
|
||||
export default function Home() {
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const {
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedEmail,
|
||||
selectedMailbox,
|
||||
selectEmail,
|
||||
selectMailbox,
|
||||
loadMockData,
|
||||
} = useEmailStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Load mock data on mount
|
||||
loadMockData();
|
||||
}, [loadMockData]);
|
||||
|
||||
const handleEmailSend = (data: {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
}) => {
|
||||
console.log("Sending email:", data);
|
||||
setShowComposer(false);
|
||||
// In a real app, this would call the JMAP client to send the email
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
console.log("Reply to:", selectedEmail?.id);
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleReplyAll = () => {
|
||||
console.log("Reply all to:", selectedEmail?.id);
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
console.log("Forward:", selectedEmail?.id);
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
console.log("Delete:", selectedEmail?.id);
|
||||
selectEmail(null);
|
||||
};
|
||||
|
||||
const handleArchive = () => {
|
||||
console.log("Archive:", selectedEmail?.id);
|
||||
selectEmail(null);
|
||||
};
|
||||
|
||||
const handleToggleStar = () => {
|
||||
console.log("Toggle star:", selectedEmail?.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMailboxSelect={selectMailbox}
|
||||
onCompose={() => setShowComposer(true)}
|
||||
/>
|
||||
|
||||
{/* Email List */}
|
||||
<div className="w-80 border-r flex-shrink-0">
|
||||
<EmailList
|
||||
emails={emails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
onEmailSelect={selectEmail}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Viewer */}
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
/>
|
||||
|
||||
{/* Email Composer Modal */}
|
||||
{showComposer && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="w-full max-w-3xl h-[600px] mx-4">
|
||||
<EmailComposer
|
||||
onSend={handleEmailSend}
|
||||
onClose={() => setShowComposer(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,15 +16,73 @@ interface EmailComposerProps {
|
||||
}) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: {
|
||||
from?: { email?: string; name?: string }[];
|
||||
to?: { email?: string; name?: string }[];
|
||||
cc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
receivedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function EmailComposer({ onSend, onClose, className }: EmailComposerProps) {
|
||||
const [to, setTo] = useState("");
|
||||
const [cc, setCc] = useState("");
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onClose,
|
||||
className,
|
||||
mode = 'compose',
|
||||
replyTo
|
||||
}: EmailComposerProps) {
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
if (!replyTo) return "";
|
||||
if (mode === 'reply') {
|
||||
return replyTo.from?.[0]?.email || "";
|
||||
} else if (mode === 'replyAll') {
|
||||
const from = replyTo.from?.[0]?.email || "";
|
||||
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
|
||||
return [from, originalTo].filter(Boolean).join(", ");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getInitialCc = () => {
|
||||
if (!replyTo || mode !== 'replyAll') return "";
|
||||
return replyTo.cc?.map(r => r.email).join(", ") || "";
|
||||
};
|
||||
|
||||
const getInitialSubject = () => {
|
||||
if (!replyTo?.subject) return "";
|
||||
if (mode === 'forward') {
|
||||
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getInitialBody = () => {
|
||||
if (!replyTo?.body) return "";
|
||||
|
||||
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : "Unknown";
|
||||
|
||||
if (mode === 'forward') {
|
||||
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const [to, setTo] = useState(getInitialTo());
|
||||
const [cc, setCc] = useState(getInitialCc());
|
||||
const [bcc, setBcc] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [subject, setSubject] = useState(getInitialSubject());
|
||||
const [body, setBody] = useState(getInitialBody());
|
||||
const [showCc, setShowCc] = useState(!!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(false);
|
||||
|
||||
const handleSend = () => {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { formatDate, truncateText } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Paperclip, Star } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle } from "lucide-react";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
@@ -11,39 +12,125 @@ interface EmailListItemProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// Color tag mapping - using lighter backgrounds for better readability
|
||||
const colorTags = {
|
||||
red: "bg-red-50 dark:bg-red-950/30",
|
||||
orange: "bg-orange-50 dark:bg-orange-950/30",
|
||||
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
|
||||
green: "bg-green-50 dark:bg-green-950/30",
|
||||
blue: "bg-blue-50 dark:bg-blue-950/30",
|
||||
purple: "bg-purple-50 dark:bg-purple-950/30",
|
||||
pink: "bg-pink-50 dark:bg-pink-950/30",
|
||||
} as const;
|
||||
|
||||
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
if (!keywords) return null;
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
||||
const color = key.replace("$color:", "");
|
||||
return colorTags[color as keyof typeof colorTags] || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function EmailListItem({ email, selected, onClick }: EmailListItemProps) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const sender = email.from?.[0];
|
||||
const colorTag = getEmailColor(email.keywords);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-gray-200",
|
||||
selected ? "bg-gray-50" : "hover:bg-gray-50/50",
|
||||
isUnread && "font-semibold"
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-gray-100 dark:border-gray-800",
|
||||
// Apply color tag as background, with selected and unread states
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-blue-50 dark:bg-blue-900/30"
|
||||
: "bg-white dark:bg-gray-950"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-gray-50 dark:hover:bg-gray-900 hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !colorTag && "bg-blue-50/30 dark:bg-blue-900/10"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={cn("text-sm", isUnread ? "font-semibold" : "font-normal")}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
{isStarred && <Star className="w-3 h-3 fill-current text-yellow-500" />}
|
||||
{email.hasAttachment && <Paperclip className="w-3 h-3 text-gray-500" />}
|
||||
<span className="text-xs text-gray-500 ml-auto">
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm mb-1">
|
||||
<span className={cn(isUnread ? "font-semibold" : "font-normal")}>
|
||||
<div className="flex items-start gap-3 px-4 py-4">
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
isUnread
|
||||
? "font-bold text-gray-900 dark:text-gray-100 text-sm"
|
||||
: "font-medium text-gray-700 dark:text-gray-300 text-sm"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
|
||||
Important
|
||||
</span>
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-gray-700 dark:text-gray-300 font-semibold"
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1",
|
||||
isUnread
|
||||
? "font-semibold text-gray-900 dark:text-gray-100 text-sm"
|
||||
: "font-normal text-gray-800 dark:text-gray-200 text-sm"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-gray-700 dark:text-gray-300"
|
||||
: "text-gray-600 dark:text-gray-400"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{truncateText(email.preview || "", 100)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,26 +3,62 @@
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { EmailListItem } from "./email-list-item";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
className
|
||||
className,
|
||||
isLoading = false
|
||||
}: EmailListProps) {
|
||||
// Loading skeleton component
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="animate-pulse">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="border-b border-gray-100 dark:border-gray-800 px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-16" />
|
||||
</div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{emails.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
<p>No emails to display</p>
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* List Header */}
|
||||
<div className="px-4 py-3 border-b bg-gray-50 dark:bg-gray-900 dark:border-gray-800">
|
||||
<h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{isLoading ? 'Loading...' : emails.length > 0 ? `${emails.length} conversations` : 'No conversations'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Email List */}
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
{isLoading ? (
|
||||
<LoadingSkeleton />
|
||||
) : emails.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 text-gray-500">
|
||||
<Inbox className="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-base font-medium text-gray-600 dark:text-gray-400">No emails in this mailbox</p>
|
||||
<p className="text-sm mt-1 text-gray-400 dark:text-gray-500">New messages will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
emails.map((email) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+323
-42
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import {
|
||||
Inbox,
|
||||
Send,
|
||||
@@ -13,8 +16,16 @@ import {
|
||||
PenSquare,
|
||||
Search,
|
||||
Menu,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -22,38 +33,247 @@ interface SidebarProps {
|
||||
selectedMailbox?: string;
|
||||
onMailboxSelect?: (mailboxId: string) => void;
|
||||
onCompose?: () => void;
|
||||
onLogout?: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
quota?: { used: number; total: number } | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultMailboxes = [
|
||||
{ id: "inbox", name: "Inbox", icon: Inbox, unread: 12 },
|
||||
{ id: "sent", name: "Sent", icon: Send, unread: 0 },
|
||||
{ id: "drafts", name: "Drafts", icon: File, unread: 2 },
|
||||
{ id: "starred", name: "Starred", icon: Star, unread: 0 },
|
||||
{ id: "archive", name: "Archive", icon: Archive, unread: 0 },
|
||||
{ id: "trash", name: "Trash", icon: Trash2, unread: 0 },
|
||||
];
|
||||
// Map role to icon
|
||||
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean) => {
|
||||
const lowerName = name?.toLowerCase() || "";
|
||||
|
||||
if (hasChildren) {
|
||||
// For folders with children, return open/closed folder icon
|
||||
return isExpanded ? FolderOpen : Folder;
|
||||
}
|
||||
|
||||
if (role === "inbox" || lowerName.includes("inbox")) return Inbox;
|
||||
if (role === "sent" || lowerName.includes("sent")) return Send;
|
||||
if (role === "drafts" || lowerName.includes("draft")) return File;
|
||||
if (role === "trash" || lowerName.includes("trash")) return Trash2;
|
||||
if (role === "archive" || lowerName.includes("archive")) return Archive;
|
||||
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
|
||||
return Inbox; // Default icon
|
||||
};
|
||||
|
||||
// Component for rendering a single mailbox node with its children
|
||||
function MailboxTreeItem({
|
||||
node,
|
||||
selectedMailbox,
|
||||
expandedFolders,
|
||||
onMailboxSelect,
|
||||
onToggleExpand,
|
||||
isCollapsed,
|
||||
}: {
|
||||
node: MailboxNode;
|
||||
selectedMailbox: string;
|
||||
expandedFolders: Set<string>;
|
||||
onMailboxSelect?: (id: string) => void;
|
||||
onToggleExpand: (id: string) => void;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded);
|
||||
const indentPixels = node.depth * 16; // 16px per depth level
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"group w-full flex items-center px-2 py-1 text-sm transition-all duration-200",
|
||||
selectedMailbox === node.id
|
||||
? "bg-blue-50 text-blue-900 dark:bg-blue-900/30 dark:text-blue-100"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800 dark:text-gray-300",
|
||||
node.depth === 0 && "font-medium" // Root folders are slightly bolder
|
||||
)}
|
||||
>
|
||||
{/* Expand/Collapse Chevron */}
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand(node.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||
"hover:bg-gray-200 active:bg-gray-300"
|
||||
)}
|
||||
style={{ marginLeft: indentPixels }}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mailbox Button */}
|
||||
<button
|
||||
onClick={() => onMailboxSelect?.(node.id)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center text-left py-1 px-1 rounded",
|
||||
"transition-colors duration-150"
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
|
||||
}}
|
||||
title={isCollapsed ? node.name : undefined}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-4 h-4 mr-2 flex-shrink-0 transition-colors",
|
||||
hasChildren && isExpanded && "text-blue-600 dark:text-blue-400",
|
||||
selectedMailbox === node.id && "text-blue-700 dark:text-blue-300",
|
||||
!hasChildren && node.depth > 0 && "text-gray-600 dark:text-gray-400"
|
||||
)} />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
{node.unreadEmails > 0 && (
|
||||
<span className={cn(
|
||||
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
|
||||
selectedMailbox === node.id
|
||||
? "bg-blue-700 text-white dark:bg-blue-600"
|
||||
: "bg-gray-800 text-white dark:bg-gray-600"
|
||||
)}>
|
||||
{node.unreadEmails}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Render children if expanded */}
|
||||
{hasChildren && isExpanded && !isCollapsed && (
|
||||
<div className="relative">
|
||||
{node.children.map((child) => (
|
||||
<MailboxTreeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedMailbox={selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
mailboxes = [],
|
||||
selectedMailbox = "inbox",
|
||||
selectedMailbox = "",
|
||||
onMailboxSelect,
|
||||
onCompose,
|
||||
onLogout,
|
||||
onSearch,
|
||||
quota,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const { theme, setTheme, resolvedTheme } = useThemeStore();
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
// Load expanded folders from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('expandedMailboxes');
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
setExpandedFolders(new Set(parsed));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse expanded mailboxes:', e);
|
||||
}
|
||||
} else {
|
||||
// By default, expand root folders that have children
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const defaultExpanded = tree
|
||||
.filter(node => node.children.length > 0)
|
||||
.map(node => node.id);
|
||||
setExpandedFolders(new Set(defaultExpanded));
|
||||
}
|
||||
}, [mailboxes]);
|
||||
|
||||
// Save expanded folders to localStorage when changed
|
||||
const handleToggleExpand = (mailboxId: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mailboxId)) {
|
||||
next.delete(mailboxId);
|
||||
} else {
|
||||
next.add(mailboxId);
|
||||
}
|
||||
localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim() && onSearch) {
|
||||
onSearch(searchQuery);
|
||||
}
|
||||
};
|
||||
|
||||
// Build hierarchical mailbox tree
|
||||
const mailboxTree = buildMailboxTree(mailboxes);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!selectedMailbox || isCollapsed) return;
|
||||
|
||||
// Find the selected node in the tree
|
||||
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === selectedMailbox) return node;
|
||||
const found = findNode(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectedNode = findNode(mailboxTree);
|
||||
if (!selectedNode) return;
|
||||
|
||||
// Handle arrow keys for expand/collapse
|
||||
if (e.key === 'ArrowRight' && selectedNode.children.length > 0) {
|
||||
// Expand folder
|
||||
if (!expandedFolders.has(selectedMailbox)) {
|
||||
handleToggleExpand(selectedMailbox);
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) {
|
||||
// Collapse folder
|
||||
if (expandedFolders.has(selectedMailbox)) {
|
||||
handleToggleExpand(selectedMailbox);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full border-r transition-all duration-300",
|
||||
"bg-white dark:bg-gray-950 dark:border-gray-800",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b dark:border-gray-800">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -64,7 +284,7 @@ export function Sidebar({
|
||||
{!isCollapsed && (
|
||||
<Button onClick={onCompose} className="ml-2 flex-1">
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
Compose
|
||||
{t("compose")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -72,51 +292,112 @@ export function Sidebar({
|
||||
{/* Search */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search mail..."
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mailbox List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="py-2">
|
||||
{defaultMailboxes.map((mailbox) => (
|
||||
<button
|
||||
key={mailbox.id}
|
||||
onClick={() => onMailboxSelect?.(mailbox.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center px-4 py-2 text-sm hover:bg-gray-100 transition-colors",
|
||||
selectedMailbox === mailbox.id && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<mailbox.icon className="w-4 h-4 mr-3 flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">{mailbox.name}</span>
|
||||
{mailbox.unread > 0 && (
|
||||
<span className="text-xs bg-gray-900 text-white rounded-full px-2 py-0.5">
|
||||
{mailbox.unread}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="py-1">
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-gray-500">
|
||||
{!isCollapsed && t("loading_mailboxes")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Render hierarchical mailbox tree */}
|
||||
{mailboxTree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 py-3 border-t text-xs text-gray-500">
|
||||
<div>Storage: 2.5 GB / 15 GB</div>
|
||||
<div className="px-4 py-3 border-t dark:border-gray-800">
|
||||
{quota && quota.total > 0 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{t("storage")}: {formatFileSize(quota.used)} / {formatFileSize(quota.total)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="mb-3">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center p-1.5 rounded transition-all",
|
||||
theme === 'light'
|
||||
? "bg-white dark:bg-gray-700 shadow-sm"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
)}
|
||||
title="Light mode"
|
||||
>
|
||||
<Sun className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center p-1.5 rounded transition-all",
|
||||
theme === 'dark'
|
||||
? "bg-white dark:bg-gray-700 shadow-sm"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
)}
|
||||
title="Dark mode"
|
||||
>
|
||||
<Moon className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center p-1.5 rounded transition-all",
|
||||
theme === 'system'
|
||||
? "bg-white dark:bg-gray-700 shadow-sm"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
)}
|
||||
title="System theme"
|
||||
>
|
||||
<Monitor className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onLogout && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLogout}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
{t("sign_out")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||
|
||||
useEffect(() => {
|
||||
initializeTheme();
|
||||
}, [initializeTheme]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AvatarProps {
|
||||
name?: string;
|
||||
email?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
||||
const getInitials = () => {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
if (email) {
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
return "?";
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
const str = name || email || "";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-8 h-8 text-xs",
|
||||
md: "w-10 h-10 text-sm",
|
||||
lg: "w-12 h-12 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full flex items-center justify-center font-semibold text-white",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: getBackgroundColor() }}
|
||||
title={name || email}
|
||||
>
|
||||
{getInitials()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,15 +12,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2",
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-all duration-200",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:focus-visible:ring-blue-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
default:
|
||||
"bg-gray-900 text-white hover:bg-gray-800",
|
||||
ghost: "hover:bg-gray-100",
|
||||
"bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 shadow-sm hover:shadow",
|
||||
ghost: "hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-100",
|
||||
outline:
|
||||
"border border-gray-300 bg-white hover:bg-gray-50",
|
||||
"border border-gray-300 bg-white hover:bg-gray-50 hover:border-gray-400 dark:border-gray-600 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:border-gray-500 dark:text-gray-100",
|
||||
}[variant],
|
||||
{
|
||||
sm: "h-9 px-3 text-sm",
|
||||
|
||||
@@ -10,10 +10,13 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm",
|
||||
"flex h-10 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm transition-all duration-200",
|
||||
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-gray-400",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2",
|
||||
"placeholder:text-gray-400 dark:placeholder:text-gray-500",
|
||||
"hover:border-gray-300 dark:hover:border-gray-600",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:border-blue-500",
|
||||
"dark:focus-visible:ring-blue-400 dark:focus-visible:border-blue-400",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { locales } from '@/i18n';
|
||||
|
||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const t = useTranslations('language');
|
||||
const currentLocale = params.locale as string;
|
||||
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Get the path without the locale prefix
|
||||
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
|
||||
|
||||
// Navigate to the same page with the new locale
|
||||
router.push(`/${newLocale}${pathWithoutLocale}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg", className)}>
|
||||
{locales.map((locale) => (
|
||||
<button
|
||||
key={locale}
|
||||
onClick={() => handleLanguageChange(locale)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
|
||||
currentLocale === locale
|
||||
? "bg-white dark:bg-gray-700 shadow-sm font-medium"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
)}
|
||||
title={t(locale === 'en' ? 'english' : 'french')}
|
||||
>
|
||||
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
|
||||
<span className="hidden sm:inline">
|
||||
{locale.toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: Toast;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
};
|
||||
|
||||
const styles = {
|
||||
success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
|
||||
error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
|
||||
info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
|
||||
warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
|
||||
};
|
||||
|
||||
export function ToastItem({ toast, onClose }: ToastProps) {
|
||||
const Icon = icons[toast.type];
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration && toast.duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose(toast.id);
|
||||
}, toast.duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-white dark:bg-gray-900 animate-slide-in",
|
||||
styles[toast.type]
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{toast.title}</h4>
|
||||
{toast.message && (
|
||||
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onClose(toast.id)}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
|
||||
export const locales = ['en', 'fr'] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = 'en';
|
||||
|
||||
export default getRequestConfig(async ({ locale }) => {
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!locales.includes(locale as any)) {
|
||||
locale = defaultLocale;
|
||||
}
|
||||
|
||||
// Use static imports for better compatibility
|
||||
const messages = locale === 'fr'
|
||||
? (await import('./locales/fr/common.json')).default
|
||||
: (await import('./locales/en/common.json')).default;
|
||||
|
||||
return {
|
||||
messages,
|
||||
timeZone: 'Europe/Paris',
|
||||
now: new Date()
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { AuthenticationResults } from './jmap/types';
|
||||
|
||||
/**
|
||||
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
|
||||
*/
|
||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
||||
const results: AuthenticationResults = {};
|
||||
|
||||
// Parse SPF
|
||||
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
|
||||
if (spfMatch) {
|
||||
results.spf = {
|
||||
result: spfMatch[1] as any,
|
||||
domain: spfMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
// Parse DKIM
|
||||
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s]+))?(?:\s+header\.s=([^\s]+))?/);
|
||||
if (dkimMatch) {
|
||||
results.dkim = {
|
||||
result: dkimMatch[1] as any,
|
||||
domain: dkimMatch[2],
|
||||
selector: dkimMatch[3]
|
||||
};
|
||||
}
|
||||
|
||||
// Parse DMARC
|
||||
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s]+))?(?:\s+policy\.dmarc=(\w+))?/);
|
||||
if (dmarcMatch) {
|
||||
results.dmarc = {
|
||||
result: dmarcMatch[1] as any,
|
||||
domain: dmarcMatch[2],
|
||||
policy: dmarcMatch[3] as any
|
||||
};
|
||||
}
|
||||
|
||||
// Parse IP reverse lookup
|
||||
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
|
||||
if (iprevMatch) {
|
||||
results.iprev = {
|
||||
result: iprevMatch[1] as any,
|
||||
ip: iprevMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse spam score from X-Spam-Result or X-Spam-Status headers
|
||||
*/
|
||||
export function parseSpamScore(header: string): { score: number; status: string } | null {
|
||||
// Try X-Spam-Status format: "No, score=-0.25"
|
||||
const statusMatch = header.match(/^(Yes|No),?\s+score=([-\d.]+)/i);
|
||||
if (statusMatch) {
|
||||
return {
|
||||
status: statusMatch[1].toLowerCase(),
|
||||
score: parseFloat(statusMatch[2])
|
||||
};
|
||||
}
|
||||
|
||||
// Try to extract just the score
|
||||
const scoreMatch = header.match(/score[=:]?\s*([-\d.]+)/i);
|
||||
if (scoreMatch) {
|
||||
const score = parseFloat(scoreMatch[1]);
|
||||
return {
|
||||
score,
|
||||
status: score > 5 ? 'spam' : 'ham'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Received headers to extract mail routing path
|
||||
*/
|
||||
export function parseReceivedHeaders(headers: string[]): Array<{
|
||||
from: string;
|
||||
by: string;
|
||||
timestamp?: string;
|
||||
protocol?: string;
|
||||
id?: string;
|
||||
}> {
|
||||
const path: any[] = [];
|
||||
|
||||
for (const header of headers) {
|
||||
const fromMatch = header.match(/from\s+([^\s]+)(?:\s+\([^)]+\))?/);
|
||||
const byMatch = header.match(/by\s+([^\s]+)/);
|
||||
const dateMatch = header.match(/;\s+(.+)$/);
|
||||
const protoMatch = header.match(/with\s+(\w+)/);
|
||||
const idMatch = header.match(/id\s+([^\s;]+)/);
|
||||
|
||||
if (fromMatch || byMatch) {
|
||||
path.push({
|
||||
from: fromMatch?.[1] || 'unknown',
|
||||
by: byMatch?.[1] || 'unknown',
|
||||
timestamp: dateMatch?.[1],
|
||||
protocol: protoMatch?.[1],
|
||||
id: idMatch?.[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable size
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security status color and icon based on result
|
||||
*/
|
||||
export function getSecurityStatus(result?: string): {
|
||||
color: string;
|
||||
icon: 'check' | 'x' | 'alert' | 'minus';
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
} {
|
||||
switch (result) {
|
||||
case 'pass':
|
||||
return {
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
icon: 'check',
|
||||
bgColor: 'bg-green-50 dark:bg-green-950/30',
|
||||
borderColor: 'border-green-200 dark:border-green-800'
|
||||
};
|
||||
case 'fail':
|
||||
case 'permerror':
|
||||
return {
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
icon: 'x',
|
||||
bgColor: 'bg-red-50 dark:bg-red-950/30',
|
||||
borderColor: 'border-red-200 dark:border-red-800'
|
||||
};
|
||||
case 'softfail':
|
||||
case 'neutral':
|
||||
case 'temperror':
|
||||
return {
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
icon: 'alert',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950/30',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'text-gray-400 dark:text-gray-500',
|
||||
icon: 'minus',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-900',
|
||||
borderColor: 'border-gray-200 dark:border-gray-700'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract list headers (List-Unsubscribe, List-Id, etc.)
|
||||
*/
|
||||
export function extractListHeaders(headers: Record<string, string | string[]>): {
|
||||
listId?: string;
|
||||
listUnsubscribe?: string;
|
||||
listHelp?: string;
|
||||
listPost?: string;
|
||||
} {
|
||||
const result: any = {};
|
||||
|
||||
if (headers['List-Id']) {
|
||||
result.listId = Array.isArray(headers['List-Id'])
|
||||
? headers['List-Id'][0]
|
||||
: headers['List-Id'];
|
||||
}
|
||||
|
||||
if (headers['List-Unsubscribe']) {
|
||||
const unsub = Array.isArray(headers['List-Unsubscribe'])
|
||||
? headers['List-Unsubscribe'][0]
|
||||
: headers['List-Unsubscribe'];
|
||||
// Extract URL from <url> format
|
||||
const match = unsub.match(/<([^>]+)>/);
|
||||
result.listUnsubscribe = match ? match[1] : unsub;
|
||||
}
|
||||
|
||||
if (headers['List-Help']) {
|
||||
result.listHelp = Array.isArray(headers['List-Help'])
|
||||
? headers['List-Help'][0]
|
||||
: headers['List-Help'];
|
||||
}
|
||||
|
||||
if (headers['List-Post']) {
|
||||
result.listPost = Array.isArray(headers['List-Post'])
|
||||
? headers['List-Post'][0]
|
||||
: headers['List-Post'];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+499
-140
@@ -1,120 +1,401 @@
|
||||
import { JmapClient, Session } from "jmap-jam";
|
||||
import type { Email, Mailbox, Thread, Identity } from "./types";
|
||||
import type { Email, Mailbox } from "./types";
|
||||
|
||||
export class JMAPEmailClient {
|
||||
private client: JmapClient;
|
||||
private session: Session | null = null;
|
||||
export class JMAPClient {
|
||||
private serverUrl: string;
|
||||
private username: string;
|
||||
private password: string;
|
||||
private authHeader: string;
|
||||
private apiUrl: string = "";
|
||||
private accountId: string = "";
|
||||
private downloadUrl: string = "";
|
||||
|
||||
constructor(serverUrl: string, username: string, password: string) {
|
||||
this.client = new JmapClient(serverUrl, {
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${username}:${password}`)}`,
|
||||
},
|
||||
});
|
||||
this.serverUrl = serverUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.authHeader = `Basic ${btoa(`${username}:${password}`)}`;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.session = await this.client.getSession();
|
||||
if (!this.session) {
|
||||
throw new Error("Failed to establish JMAP session");
|
||||
console.log('FinalJMAPClient: Connecting to', this.serverUrl);
|
||||
|
||||
// Get the session first
|
||||
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
||||
console.log('Fetching session from:', sessionUrl);
|
||||
|
||||
try {
|
||||
const sessionResponse = await fetch(sessionUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sessionResponse.ok) {
|
||||
if (sessionResponse.status === 401) {
|
||||
throw new Error('Invalid username or password');
|
||||
}
|
||||
throw new Error(`Failed to get session: ${sessionResponse.status}`);
|
||||
}
|
||||
|
||||
const session = await sessionResponse.json();
|
||||
console.log('Session:', session);
|
||||
|
||||
// Extract the API URL
|
||||
this.apiUrl = session.apiUrl;
|
||||
console.log('API URL:', this.apiUrl);
|
||||
|
||||
// Extract the download URL
|
||||
this.downloadUrl = session.downloadUrl;
|
||||
|
||||
// Extract the account ID
|
||||
const mailAccount = session.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) {
|
||||
this.accountId = mailAccount;
|
||||
} else {
|
||||
// Try to find any account
|
||||
const accounts = session.accounts;
|
||||
if (accounts && Object.keys(accounts).length > 0) {
|
||||
this.accountId = Object.keys(accounts)[0];
|
||||
} else {
|
||||
throw new Error('No mail account found in session');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Account ID:', this.accountId);
|
||||
} catch (error) {
|
||||
console.error('Connection failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async request(methodCalls: any[]): Promise<any> {
|
||||
if (!this.apiUrl) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
methodCalls: methodCalls,
|
||||
};
|
||||
|
||||
console.log('Making request to:', this.apiUrl);
|
||||
console.log('Request:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Request failed:', response.status, responseText);
|
||||
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse response:', responseText);
|
||||
throw new Error('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
console.log('Response:', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Quota/get", {
|
||||
accountId: this.accountId,
|
||||
}, "0"]
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Quota/get") {
|
||||
const quotas = response.methodResponses[0][1].list || [];
|
||||
// Find the mail quota if it exists
|
||||
const mailQuota = quotas.find((q: any) => q.resourceType === "mail" || q.scope === "mail");
|
||||
|
||||
if (mailQuota) {
|
||||
return {
|
||||
used: mailQuota.used ?? 0,
|
||||
total: mailQuota.hardLimit ?? mailQuota.limit ?? 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.log('Quota not available or not supported:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getMailboxes(): Promise<Mailbox[]> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Mailbox/get", {
|
||||
accountId: this.accountId,
|
||||
}, "0"]
|
||||
]);
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||||
const rawMailboxes = response.methodResponses[0][1].list || [];
|
||||
console.log(`Got ${rawMailboxes.length} mailboxes`);
|
||||
|
||||
const response = await this.client.request([
|
||||
["Mailbox/get", { accountId }, "0"],
|
||||
]);
|
||||
// Map and ensure all required fields are present
|
||||
const mailboxes = rawMailboxes.map((mb: any) => {
|
||||
console.log(`Mailbox: ${mb.name} (ID: ${mb.id}, Role: ${mb.role}, Emails: ${mb.totalEmails}, Unread: ${mb.unreadEmails})`);
|
||||
|
||||
return response[0][1].list as Mailbox[];
|
||||
return {
|
||||
id: mb.id,
|
||||
name: mb.name,
|
||||
parentId: mb.parentId || undefined,
|
||||
role: mb.role || undefined,
|
||||
sortOrder: mb.sortOrder ?? 0,
|
||||
totalEmails: mb.totalEmails ?? 0,
|
||||
unreadEmails: mb.unreadEmails ?? 0,
|
||||
totalThreads: mb.totalThreads ?? 0,
|
||||
unreadThreads: mb.unreadThreads ?? 0,
|
||||
myRights: mb.myRights || {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
isSubscribed: mb.isSubscribed ?? true,
|
||||
} as Mailbox;
|
||||
});
|
||||
|
||||
return mailboxes;
|
||||
}
|
||||
|
||||
throw new Error('Unexpected response format');
|
||||
} catch (error) {
|
||||
console.error('Failed to get mailboxes:', error);
|
||||
// Return default inbox with all required fields
|
||||
return [{
|
||||
id: 'INBOX',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
isSubscribed: true,
|
||||
}] as Mailbox[];
|
||||
}
|
||||
}
|
||||
|
||||
async getEmails(mailboxId?: string, limit: number = 50): Promise<Email[]> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
try {
|
||||
console.log(`Fetching emails for mailbox: ${mailboxId || 'all'}`);
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
// Build filter - only add inMailbox if we have a mailboxId
|
||||
const filter: any = {};
|
||||
if (mailboxId && mailboxId !== '') {
|
||||
filter.inMailbox = mailboxId;
|
||||
}
|
||||
|
||||
const filter: any = {};
|
||||
if (mailboxId) {
|
||||
filter.inMailbox = mailboxId;
|
||||
console.log('Email filter:', JSON.stringify(filter));
|
||||
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: filter,
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit: limit,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
accountId: this.accountId,
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Email/query",
|
||||
path: "/ids",
|
||||
},
|
||||
properties: [
|
||||
"id",
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"size",
|
||||
"receivedAt",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"subject",
|
||||
"preview",
|
||||
"hasAttachment",
|
||||
],
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
// Log query results
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/query") {
|
||||
const queryResult = response.methodResponses[0][1];
|
||||
console.log(`Email/query returned ${queryResult.total} total, ${queryResult.ids?.length || 0} ids`);
|
||||
}
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "Email/get") {
|
||||
const emails = response.methodResponses[1][1].list || [];
|
||||
console.log(`Got ${emails.length} emails from Email/get`);
|
||||
|
||||
// Log first few emails for debugging
|
||||
emails.slice(0, 3).forEach((email: any) => {
|
||||
console.log(`Email: "${email.subject}" from ${email.from?.[0]?.email || 'unknown'}`);
|
||||
});
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
console.warn('Unexpected email response format');
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get emails:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await this.client.request([
|
||||
["Email/query", {
|
||||
accountId,
|
||||
filter,
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
accountId,
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Email/query",
|
||||
path: "/ids",
|
||||
},
|
||||
properties: [
|
||||
"id", "threadId", "mailboxIds", "keywords", "size",
|
||||
"receivedAt", "from", "to", "cc", "subject", "preview",
|
||||
"hasAttachment",
|
||||
],
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
return response[1][1].list as Email[];
|
||||
}
|
||||
|
||||
async getEmail(emailId: string): Promise<Email | null> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Email/get", {
|
||||
accountId: this.accountId,
|
||||
ids: [emailId],
|
||||
properties: [
|
||||
"id",
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"size",
|
||||
"receivedAt",
|
||||
"sentAt",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"replyTo",
|
||||
"subject",
|
||||
"preview",
|
||||
"textBody",
|
||||
"htmlBody",
|
||||
"bodyValues",
|
||||
"hasAttachment",
|
||||
"attachments",
|
||||
"messageId",
|
||||
"inReplyTo",
|
||||
"references",
|
||||
"headers",
|
||||
],
|
||||
fetchTextBodyValues: true,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 256000,
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/get") {
|
||||
const emails = response.methodResponses[0][1].list || [];
|
||||
const email = emails[0];
|
||||
|
||||
const response = await this.client.request([
|
||||
["Email/get", {
|
||||
accountId,
|
||||
ids: [emailId],
|
||||
}, "0"],
|
||||
]);
|
||||
if (email) {
|
||||
// Parse headers if available
|
||||
if (email.headers) {
|
||||
// Import the parsing functions
|
||||
const { parseAuthenticationResults, parseSpamScore } = await import('@/lib/email-headers');
|
||||
|
||||
const emails = response[0][1].list as Email[];
|
||||
return emails[0] || null;
|
||||
// Parse Authentication-Results header
|
||||
const authResultsHeader = email.headers['Authentication-Results'];
|
||||
if (authResultsHeader) {
|
||||
const headerValue = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader;
|
||||
email.authenticationResults = parseAuthenticationResults(headerValue);
|
||||
}
|
||||
|
||||
// Parse Spam headers
|
||||
const spamHeaders = ['X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score'];
|
||||
for (const header of spamHeaders) {
|
||||
if (email.headers[header]) {
|
||||
const headerValue = Array.isArray(email.headers[header]) ? email.headers[header][0] : email.headers[header];
|
||||
const spamResult = parseSpamScore(headerValue);
|
||||
if (spamResult) {
|
||||
email.spamScore = spamResult.score;
|
||||
email.spamStatus = spamResult.status;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get email:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
|
||||
await this.client.request([
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId,
|
||||
accountId: this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
keywords: {
|
||||
"$seen": read,
|
||||
},
|
||||
"keywords/$seen": read,
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
|
||||
await this.client.request([
|
||||
async toggleStar(emailId: string, starred: boolean): Promise<void> {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId,
|
||||
accountId: this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: {
|
||||
[toMailboxId]: true,
|
||||
},
|
||||
"keywords/$flagged": starred,
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
keywords,
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
@@ -122,48 +403,71 @@ export class JMAPEmailClient {
|
||||
}
|
||||
|
||||
async deleteEmail(emailId: string): Promise<void> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
|
||||
await this.client.request([
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId,
|
||||
accountId: this.accountId,
|
||||
destroy: [emailId],
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
async searchEmails(query: string, limit: number = 50): Promise<Email[]> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
|
||||
const response = await this.client.request([
|
||||
["Email/query", {
|
||||
accountId,
|
||||
filter: {
|
||||
text: query,
|
||||
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: { [toMailboxId]: true },
|
||||
},
|
||||
},
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
accountId,
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Email/query",
|
||||
path: "/ids",
|
||||
},
|
||||
properties: [
|
||||
"id", "threadId", "mailboxIds", "keywords", "size",
|
||||
"receivedAt", "from", "to", "cc", "subject", "preview",
|
||||
"hasAttachment",
|
||||
],
|
||||
}, "1"],
|
||||
]);
|
||||
}
|
||||
|
||||
return response[1][1].list as Email[];
|
||||
async searchEmails(query: string, limit: number = 50): Promise<Email[]> {
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: {
|
||||
text: query,
|
||||
},
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit: limit,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
accountId: this.accountId,
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Email/query",
|
||||
path: "/ids",
|
||||
},
|
||||
properties: [
|
||||
"id",
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"size",
|
||||
"receivedAt",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"subject",
|
||||
"preview",
|
||||
"hasAttachment",
|
||||
],
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "Email/get") {
|
||||
return response.methodResponses[1][1].list || [];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(
|
||||
@@ -171,48 +475,103 @@ export class JMAPEmailClient {
|
||||
subject: string,
|
||||
body: string,
|
||||
cc?: string[],
|
||||
bcc?: string[],
|
||||
attachments?: any[]
|
||||
bcc?: string[]
|
||||
): Promise<void> {
|
||||
if (!this.session) throw new Error("Not connected");
|
||||
const emailId = `draft-${Date.now()}`;
|
||||
|
||||
const accountId = this.session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
||||
|
||||
// Create email draft
|
||||
const emailCreate = {
|
||||
from: [{ email: this.session.username }],
|
||||
to: to.map(email => ({ email })),
|
||||
cc: cc?.map(email => ({ email })),
|
||||
bcc: bcc?.map(email => ({ email })),
|
||||
subject,
|
||||
textBody: [{ type: "text/plain", value: body }],
|
||||
htmlBody: [{ type: "text/html", value: body.replace(/\n/g, "<br>") }],
|
||||
};
|
||||
|
||||
// Create and send email
|
||||
await this.client.request([
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId,
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
draft: emailCreate,
|
||||
[emailId]: {
|
||||
from: [{ email: this.username }],
|
||||
to: to.map(email => ({ email })),
|
||||
cc: cc?.map(email => ({ email })),
|
||||
bcc: bcc?.map(email => ({ email })),
|
||||
subject: subject,
|
||||
keywords: {},
|
||||
mailboxIds: {},
|
||||
bodyValues: {
|
||||
"1": {
|
||||
value: body,
|
||||
},
|
||||
},
|
||||
textBody: [
|
||||
{
|
||||
partId: "1",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
["EmailSubmission/set", {
|
||||
accountId,
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
submission: {
|
||||
emailId: "#draft",
|
||||
envelope: {
|
||||
mailFrom: { email: this.session.username },
|
||||
rcptTo: [
|
||||
...to.map(email => ({ email })),
|
||||
...(cc || []).map(email => ({ email })),
|
||||
...(bcc || []).map(email => ({ email })),
|
||||
],
|
||||
},
|
||||
"1": {
|
||||
emailId: `#${emailId}`,
|
||||
},
|
||||
},
|
||||
}, "1"],
|
||||
]);
|
||||
}
|
||||
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
if (!this.downloadUrl) {
|
||||
throw new Error('Download URL not available. Please reconnect.');
|
||||
}
|
||||
|
||||
// The downloadUrl is a URI Template (RFC 6570 level 1) with variables
|
||||
// like {accountId}, {blobId}, {name}, and {type}
|
||||
let url = this.downloadUrl;
|
||||
|
||||
// Replace template variables with actual values
|
||||
url = url.replace('{accountId}', encodeURIComponent(this.accountId));
|
||||
url = url.replace('{blobId}', encodeURIComponent(blobId));
|
||||
|
||||
// Replace {name} - use a default if not provided
|
||||
const fileName = name || 'download';
|
||||
url = url.replace('{name}', encodeURIComponent(fileName));
|
||||
|
||||
// Replace {type} - URL encode it since it may contain slashes (e.g., "application/pdf")
|
||||
// If type is not provided, use a generic binary type
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
url = url.replace('{type}', encodeURIComponent(mimeType));
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download attachment: ${response.status}`);
|
||||
}
|
||||
|
||||
// Get the blob from the response
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a temporary URL for the blob
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Create a temporary anchor element and trigger download
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = name || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,46 @@ export interface Email {
|
||||
preview?: string;
|
||||
textBody?: EmailBodyPart[];
|
||||
htmlBody?: EmailBodyPart[];
|
||||
bodyValues?: Record<string, EmailBodyValue>;
|
||||
attachments?: Attachment[];
|
||||
hasAttachment: boolean;
|
||||
// Extended header information
|
||||
messageId?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
headers?: Record<string, string | string[]>;
|
||||
// Security headers parsed
|
||||
authenticationResults?: AuthenticationResults;
|
||||
spamScore?: number;
|
||||
spamStatus?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationResults {
|
||||
spf?: {
|
||||
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
ip?: string;
|
||||
};
|
||||
dkim?: {
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
};
|
||||
dmarc?: {
|
||||
result: 'pass' | 'fail' | 'none';
|
||||
policy?: 'reject' | 'quarantine' | 'none';
|
||||
domain?: string;
|
||||
};
|
||||
iprev?: {
|
||||
result: 'pass' | 'fail';
|
||||
ip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmailBodyValue {
|
||||
value: string;
|
||||
isEncodingProblem?: boolean;
|
||||
isTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface EmailAddress {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Mailbox } from "./jmap/types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -29,4 +30,81 @@ export function formatDate(date: Date | string): string {
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength).trim() + "...";
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Types for mailbox tree
|
||||
export interface MailboxNode extends Mailbox {
|
||||
children: MailboxNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
// Build a hierarchical tree structure from flat mailbox array
|
||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
const mailboxMap = new Map<string, MailboxNode>();
|
||||
const rootMailboxes: MailboxNode[] = [];
|
||||
|
||||
// First pass: create nodes for all mailboxes
|
||||
mailboxes.forEach(mailbox => {
|
||||
mailboxMap.set(mailbox.id, {
|
||||
...mailbox,
|
||||
children: [],
|
||||
depth: 0
|
||||
});
|
||||
});
|
||||
|
||||
// Second pass: build tree structure
|
||||
mailboxes.forEach(mailbox => {
|
||||
const node = mailboxMap.get(mailbox.id)!;
|
||||
|
||||
if (mailbox.parentId && mailboxMap.has(mailbox.parentId)) {
|
||||
const parent = mailboxMap.get(mailbox.parentId)!;
|
||||
parent.children.push(node);
|
||||
node.depth = parent.depth + 1;
|
||||
} else {
|
||||
// Root level mailbox or orphaned mailbox
|
||||
rootMailboxes.push(node);
|
||||
node.depth = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort mailboxes at each level by sortOrder
|
||||
const sortNodes = (nodes: MailboxNode[]) => {
|
||||
nodes.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
nodes.forEach(node => {
|
||||
if (node.children.length > 0) {
|
||||
sortNodes(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
sortNodes(rootMailboxes);
|
||||
|
||||
return rootMailboxes;
|
||||
}
|
||||
|
||||
// Flatten a mailbox tree for rendering with proper depth info
|
||||
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
|
||||
const result: MailboxNode[] = [];
|
||||
|
||||
const traverse = (nodes: MailboxNode[], depth: number = 0) => {
|
||||
nodes.forEach(node => {
|
||||
result.push({ ...node, depth });
|
||||
if (node.children.length > 0) {
|
||||
traverse(node.children, depth + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(nodes);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "root.cloud Webmail",
|
||||
"subtitle": "Sign in to access your email",
|
||||
"server_label": "JMAP Server",
|
||||
"server_placeholder": "https://mail.example.com",
|
||||
"username_label": "Username",
|
||||
"username_placeholder": "user@example.com",
|
||||
"password_label": "Password",
|
||||
"password_placeholder": "Enter your password",
|
||||
"sign_in": "Sign in",
|
||||
"signing_in": "Signing in...",
|
||||
"secure_connection": "Secure JMAP connection to your email server",
|
||||
"security_note": "For security, passwords are never stored. Use your browser's password manager for convenience.",
|
||||
"error": {
|
||||
"invalid_credentials": "Invalid username or password",
|
||||
"connection_failed": "Failed to connect to the server",
|
||||
"generic": "An error occurred. Please try again."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"compose": "Compose",
|
||||
"search_placeholder": "Search mail...",
|
||||
"storage": "Storage",
|
||||
"sign_out": "Sign out",
|
||||
"loading_mailboxes": "Loading mailboxes...",
|
||||
"theme": {
|
||||
"light": "Light mode",
|
||||
"dark": "Dark mode",
|
||||
"system": "System theme"
|
||||
},
|
||||
"mailboxes": {
|
||||
"inbox": "Inbox",
|
||||
"sent": "Sent",
|
||||
"drafts": "Drafts",
|
||||
"trash": "Trash",
|
||||
"archive": "Archive",
|
||||
"starred": "Starred",
|
||||
"all_mail": "All Mail",
|
||||
"spam": "Spam",
|
||||
"important": "Important"
|
||||
},
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "No emails",
|
||||
"no_emails_description": "Start by composing a new email",
|
||||
"loading": "Loading emails...",
|
||||
"unread": "unread",
|
||||
"to_me": "To me",
|
||||
"to_recipients": "To {{count}} recipients",
|
||||
"and_others": "and {{count}} others",
|
||||
"draft": "Draft",
|
||||
"starred": "Starred"
|
||||
},
|
||||
"email_viewer": {
|
||||
"no_email_selected": "No email selected",
|
||||
"no_email_description": "Select an email from the list to view it here",
|
||||
"reply": "Reply",
|
||||
"reply_all": "Reply All",
|
||||
"forward": "Forward",
|
||||
"delete": "Delete",
|
||||
"archive": "Archive",
|
||||
"star": "Star",
|
||||
"unstar": "Unstar",
|
||||
"mark_unread": "Mark as unread",
|
||||
"mark_read": "Mark as read",
|
||||
"print": "Print",
|
||||
"attachments": "Attachments",
|
||||
"download": "Download",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"date": "Date",
|
||||
"subject": "Subject",
|
||||
"show_details": "Show details",
|
||||
"hide_details": "Hide details",
|
||||
"external_content_warning": "Images and external content have been blocked",
|
||||
"load_external_content": "Load images",
|
||||
"message_details": "Message Details",
|
||||
"authentication": {
|
||||
"title": "Authentication",
|
||||
"status": {
|
||||
"verified": "Verified",
|
||||
"warning": "Warning",
|
||||
"none": "Not authenticated"
|
||||
},
|
||||
"spf": {
|
||||
"pass": "SPF Pass",
|
||||
"fail": "SPF Fail",
|
||||
"none": "No SPF"
|
||||
},
|
||||
"dkim": {
|
||||
"pass": "DKIM Valid",
|
||||
"fail": "DKIM Invalid",
|
||||
"none": "No DKIM"
|
||||
},
|
||||
"dmarc": {
|
||||
"pass": "DMARC Pass",
|
||||
"fail": "DMARC Fail",
|
||||
"none": "No DMARC"
|
||||
},
|
||||
"spam_score": "Spam Score"
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Routing",
|
||||
"received": "Received",
|
||||
"message_id": "Message ID",
|
||||
"list_info": "List Information"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Color Tag",
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "New Message",
|
||||
"reply_to": "Reply",
|
||||
"reply_all_to": "Reply All",
|
||||
"forward_message": "Forward",
|
||||
"to": "To",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"subject": "Subject",
|
||||
"body_placeholder": "Write your message...",
|
||||
"send": "Send",
|
||||
"cancel": "Cancel",
|
||||
"attach": "Attach files",
|
||||
"quote": {
|
||||
"reply_header": "On {{date}}, {{sender}} wrote:",
|
||||
"forward_header": "---------- Forwarded message ----------",
|
||||
"from": "From: {{sender}}",
|
||||
"date": "Date: {{date}}",
|
||||
"subject": "Subject: {{subject}}",
|
||||
"to": "To: {{recipients}}"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
"refresh": "Refresh",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"logout": "Logout",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"notifications": {
|
||||
"email_sent": "Email sent successfully",
|
||||
"email_deleted": "Email deleted",
|
||||
"email_archived": "Email archived",
|
||||
"email_starred": "Email starred",
|
||||
"email_unstarred": "Email unstarred",
|
||||
"email_marked_read": "Email marked as read",
|
||||
"email_marked_unread": "Email marked as unread",
|
||||
"copied_to_clipboard": "Copied to clipboard",
|
||||
"error_sending": "Failed to send email",
|
||||
"error_deleting": "Failed to delete email",
|
||||
"error_loading": "Failed to load emails"
|
||||
},
|
||||
"date": {
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"this_week": "This week",
|
||||
"last_week": "Last week",
|
||||
"this_month": "This month",
|
||||
"older": "Older",
|
||||
"just_now": "Just now",
|
||||
"minutes_ago": "{{count}} minute ago",
|
||||
"minutes_ago_plural": "{{count}} minutes ago",
|
||||
"hours_ago": "{{count}} hour ago",
|
||||
"hours_ago_plural": "{{count}} hours ago",
|
||||
"days_ago": "{{count}} day ago",
|
||||
"days_ago_plural": "{{count}} days ago"
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "root.cloud Webmail",
|
||||
"subtitle": "Connectez-vous pour accéder à vos emails",
|
||||
"server_label": "Serveur JMAP",
|
||||
"server_placeholder": "https://mail.exemple.com",
|
||||
"username_label": "Nom d'utilisateur",
|
||||
"username_placeholder": "utilisateur@exemple.com",
|
||||
"password_label": "Mot de passe",
|
||||
"password_placeholder": "Entrez votre mot de passe",
|
||||
"sign_in": "Se connecter",
|
||||
"signing_in": "Connexion en cours...",
|
||||
"secure_connection": "Connexion JMAP sécurisée à votre serveur de messagerie",
|
||||
"security_note": "Pour votre sécurité, les mots de passe ne sont jamais stockés. Utilisez le gestionnaire de mots de passe de votre navigateur pour plus de commodité.",
|
||||
"error": {
|
||||
"invalid_credentials": "Nom d'utilisateur ou mot de passe invalide",
|
||||
"connection_failed": "Échec de la connexion au serveur",
|
||||
"generic": "Une erreur s'est produite. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"compose": "Composer",
|
||||
"search_placeholder": "Rechercher un email...",
|
||||
"storage": "Stockage",
|
||||
"sign_out": "Se déconnecter",
|
||||
"loading_mailboxes": "Chargement des boîtes mail...",
|
||||
"theme": {
|
||||
"light": "Mode clair",
|
||||
"dark": "Mode sombre",
|
||||
"system": "Thème système"
|
||||
},
|
||||
"mailboxes": {
|
||||
"inbox": "Boîte de réception",
|
||||
"sent": "Envoyés",
|
||||
"drafts": "Brouillons",
|
||||
"trash": "Corbeille",
|
||||
"archive": "Archives",
|
||||
"starred": "Favoris",
|
||||
"all_mail": "Tous les messages",
|
||||
"spam": "Spam",
|
||||
"important": "Important"
|
||||
},
|
||||
"expand": "Développer",
|
||||
"collapse": "Réduire"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Aucun email",
|
||||
"no_emails_description": "Commencez par composer un nouvel email",
|
||||
"loading": "Chargement des emails...",
|
||||
"unread": "non lu",
|
||||
"to_me": "À moi",
|
||||
"to_recipients": "À {{count}} destinataires",
|
||||
"and_others": "et {{count}} autres",
|
||||
"draft": "Brouillon",
|
||||
"starred": "Favori"
|
||||
},
|
||||
"email_viewer": {
|
||||
"no_email_selected": "Aucun email sélectionné",
|
||||
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous",
|
||||
"forward": "Transférer",
|
||||
"delete": "Supprimer",
|
||||
"archive": "Archiver",
|
||||
"star": "Marquer comme favori",
|
||||
"unstar": "Retirer des favoris",
|
||||
"mark_unread": "Marquer comme non lu",
|
||||
"mark_read": "Marquer comme lu",
|
||||
"print": "Imprimer",
|
||||
"attachments": "Pièces jointes",
|
||||
"download": "Télécharger",
|
||||
"from": "De",
|
||||
"to": "À",
|
||||
"cc": "CC",
|
||||
"bcc": "CCI",
|
||||
"date": "Date",
|
||||
"subject": "Objet",
|
||||
"show_details": "Afficher les détails",
|
||||
"hide_details": "Masquer les détails",
|
||||
"external_content_warning": "Les images et le contenu externe ont été bloqués",
|
||||
"load_external_content": "Charger les images",
|
||||
"message_details": "Détails du message",
|
||||
"authentication": {
|
||||
"title": "Authentification",
|
||||
"status": {
|
||||
"verified": "Vérifié",
|
||||
"warning": "Attention",
|
||||
"none": "Non authentifié"
|
||||
},
|
||||
"spf": {
|
||||
"pass": "SPF Validé",
|
||||
"fail": "SPF Échoué",
|
||||
"none": "Pas de SPF"
|
||||
},
|
||||
"dkim": {
|
||||
"pass": "DKIM Valide",
|
||||
"fail": "DKIM Invalide",
|
||||
"none": "Pas de DKIM"
|
||||
},
|
||||
"dmarc": {
|
||||
"pass": "DMARC Validé",
|
||||
"fail": "DMARC Échoué",
|
||||
"none": "Pas de DMARC"
|
||||
},
|
||||
"spam_score": "Score de spam"
|
||||
},
|
||||
"headers": {
|
||||
"routing": "Routage",
|
||||
"received": "Reçu",
|
||||
"message_id": "ID du message",
|
||||
"list_info": "Information de liste"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Étiquette de couleur",
|
||||
"red": "Rouge",
|
||||
"orange": "Orange",
|
||||
"yellow": "Jaune",
|
||||
"green": "Vert",
|
||||
"blue": "Bleu",
|
||||
"purple": "Violet",
|
||||
"none": "Aucune"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "Nouveau message",
|
||||
"reply_to": "Répondre",
|
||||
"reply_all_to": "Répondre à tous",
|
||||
"forward_message": "Transférer",
|
||||
"to": "À",
|
||||
"cc": "CC",
|
||||
"bcc": "CCI",
|
||||
"subject": "Objet",
|
||||
"body_placeholder": "Écrivez votre message...",
|
||||
"send": "Envoyer",
|
||||
"cancel": "Annuler",
|
||||
"attach": "Joindre des fichiers",
|
||||
"quote": {
|
||||
"reply_header": "Le {{date}}, {{sender}} a écrit :",
|
||||
"forward_header": "---------- Message transféré ----------",
|
||||
"from": "De : {{sender}}",
|
||||
"date": "Date : {{date}}",
|
||||
"subject": "Objet : {{subject}}",
|
||||
"to": "À : {{recipients}}"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"close": "Fermer",
|
||||
"search": "Rechercher",
|
||||
"refresh": "Actualiser",
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide",
|
||||
"logout": "Déconnexion",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"notifications": {
|
||||
"email_sent": "Email envoyé avec succès",
|
||||
"email_deleted": "Email supprimé",
|
||||
"email_archived": "Email archivé",
|
||||
"email_starred": "Email ajouté aux favoris",
|
||||
"email_unstarred": "Email retiré des favoris",
|
||||
"email_marked_read": "Email marqué comme lu",
|
||||
"email_marked_unread": "Email marqué comme non lu",
|
||||
"copied_to_clipboard": "Copié dans le presse-papiers",
|
||||
"error_sending": "Échec de l'envoi de l'email",
|
||||
"error_deleting": "Échec de la suppression de l'email",
|
||||
"error_loading": "Échec du chargement des emails"
|
||||
},
|
||||
"date": {
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"this_week": "Cette semaine",
|
||||
"last_week": "La semaine dernière",
|
||||
"this_month": "Ce mois-ci",
|
||||
"older": "Plus ancien",
|
||||
"just_now": "À l'instant",
|
||||
"minutes_ago": "Il y a {{count}} minute",
|
||||
"minutes_ago_plural": "Il y a {{count}} minutes",
|
||||
"hours_ago": "Il y a {{count}} heure",
|
||||
"hours_ago_plural": "Il y a {{count}} heures",
|
||||
"days_ago": "Il y a {{count}} jour",
|
||||
"days_ago_plural": "Il y a {{count}} jours"
|
||||
},
|
||||
"language": {
|
||||
"title": "Langue",
|
||||
"english": "English",
|
||||
"french": "Français"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { locales, defaultLocale } from './i18n';
|
||||
|
||||
export default createMiddleware({
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'as-needed', // Don't show locale in URL for default language
|
||||
localeDetection: true // Enable browser language detection
|
||||
});
|
||||
|
||||
export const config = {
|
||||
// Skip all paths that should not be internationalized
|
||||
matcher: ['/((?!api|_next|.*\\..*).*)']
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+162
-1
@@ -8,12 +8,15 @@
|
||||
"name": "jmap-webmail",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"jmap-jam": "^0.11.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-intl": "^4.3.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -60,6 +63,66 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
|
||||
"integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "2.2.7",
|
||||
"@formatjs/intl-localematcher": "0.6.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz",
|
||||
"integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
|
||||
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz",
|
||||
"integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "2.3.4",
|
||||
"@formatjs/icu-skeleton-parser": "1.8.14",
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||
"version": "1.8.14",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz",
|
||||
"integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "2.3.4",
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
|
||||
"integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
@@ -694,6 +757,12 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@schummar/icu-type-parser": {
|
||||
"version": "1.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
|
||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -979,6 +1048,15 @@
|
||||
"tailwindcss": "4.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz",
|
||||
@@ -1009,6 +1087,12 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001746",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz",
|
||||
@@ -1080,6 +1164,12 @@
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz",
|
||||
@@ -1090,6 +1180,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
@@ -1111,6 +1210,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/intl-messageformat": {
|
||||
"version": "10.7.16",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz",
|
||||
"integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@formatjs/ecma402-abstract": "2.3.4",
|
||||
"@formatjs/fast-memoize": "2.2.7",
|
||||
"@formatjs/icu-messageformat-parser": "2.11.2",
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -1456,6 +1567,15 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz",
|
||||
@@ -1540,6 +1660,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl": {
|
||||
"version": "4.3.9",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.9.tgz",
|
||||
"integrity": "sha512-4oSROHlgy8a5Qr2vH69wxo9F6K0uc6nZM2GNzqSe6ET79DEzOmBeSijCRzD5txcI4i+XTGytu4cxFsDXLKEDpQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/amannn"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"negotiator": "^1.0.0",
|
||||
"use-intl": "^4.3.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -1855,7 +2002,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -1872,6 +2019,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-intl": {
|
||||
"version": "4.3.9",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.9.tgz",
|
||||
"integrity": "sha512-bZu+h13HIgOvsoGleQtUe4E6gM49CRm+AH36KnJVB/qb1+Beo7jr7HNrR8YWH8oaOkQfGNm6vh0HTepxng8UTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "^2.2.0",
|
||||
"@schummar/icu-type-parser": "1.21.5",
|
||||
"intl-messageformat": "^10.5.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"jmap-jam": "^0.11.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "15.5.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-intl": "^4.3.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
serverUrl: string | null;
|
||||
username: string | null;
|
||||
client: JMAPClient | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
client: null,
|
||||
|
||||
login: async (serverUrl, username, password) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
// Create JMAP client
|
||||
const client = new JMAPClient(serverUrl, username, password);
|
||||
|
||||
// Try to connect
|
||||
await client.connect();
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl,
|
||||
username,
|
||||
client,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
let errorKey = 'generic';
|
||||
|
||||
// Map common errors to translation keys
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('401') || error.message.includes('Unauthorized')) {
|
||||
errorKey = 'invalid_credentials';
|
||||
} else if (error.message.includes('network') || error.message.includes('Failed to fetch')) {
|
||||
errorKey = 'connection_failed';
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorKey,
|
||||
isAuthenticated: false,
|
||||
client: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
client: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Clear persisted storage
|
||||
localStorage.removeItem('auth-storage');
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const state = get();
|
||||
|
||||
// If authenticated but no client (e.g., after page refresh), we can't restore the session
|
||||
// because we don't store passwords for security reasons
|
||||
if (state.isAuthenticated && !state.client) {
|
||||
// Reset auth state - user will need to log in again
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
client: null,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Mark loading as complete
|
||||
set({ isLoading: false });
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
// Only persist non-sensitive data
|
||||
serverUrl: state.serverUrl,
|
||||
username: state.username,
|
||||
// Don't persist isAuthenticated since we can't restore the session without a password
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
+340
-2
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { Email, Mailbox } from "@/lib/jmap/types";
|
||||
import { JMAPClient } from "@/lib/jmap/client";
|
||||
|
||||
interface EmailStore {
|
||||
emails: Email[];
|
||||
@@ -8,6 +9,9 @@ interface EmailStore {
|
||||
selectedMailbox: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
quota: { used: number; total: number } | null;
|
||||
processingReadStatus: Set<string>; // Track emails being marked as read/unread
|
||||
|
||||
setEmails: (emails: Email[]) => void;
|
||||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||||
@@ -15,18 +19,35 @@ interface EmailStore {
|
||||
selectMailbox: (mailboxId: string) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setQuota: (quota: { used: number; total: number } | null) => void;
|
||||
|
||||
// JMAP operations
|
||||
fetchMailboxes: (client: JMAPClient) => Promise<void>;
|
||||
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>;
|
||||
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: JMAPClient) => Promise<void>;
|
||||
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[]) => Promise<void>;
|
||||
deleteEmail: (client: JMAPClient, emailId: string) => Promise<void>;
|
||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
|
||||
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
|
||||
|
||||
// Mock data for demo
|
||||
loadMockData: () => void;
|
||||
}
|
||||
|
||||
export const useEmailStore = create<EmailStore>((set) => ({
|
||||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
selectedEmail: null,
|
||||
selectedMailbox: "inbox",
|
||||
selectedMailbox: "",
|
||||
isLoading: false,
|
||||
error: null,
|
||||
searchQuery: "",
|
||||
quota: null,
|
||||
processingReadStatus: new Set(),
|
||||
|
||||
setEmails: (emails) => set({ emails }),
|
||||
setMailboxes: (mailboxes) => set({ mailboxes }),
|
||||
@@ -34,6 +55,323 @@ export const useEmailStore = create<EmailStore>((set) => ({
|
||||
selectMailbox: (mailboxId) => set({ selectedMailbox: mailboxId }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setQuota: (quota) => set({ quota }),
|
||||
|
||||
// JMAP operations
|
||||
fetchMailboxes: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const mailboxes = await client.getMailboxes();
|
||||
set({ mailboxes, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchEmails: async (client, mailboxId) => {
|
||||
set({ isLoading: true, error: null, emails: [] }); // Clear emails immediately for better loading UX
|
||||
try {
|
||||
const emails = await client.getEmails(mailboxId || get().selectedMailbox);
|
||||
set({ emails, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to fetch emails",
|
||||
isLoading: false,
|
||||
emails: []
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchEmailContent: async (client, emailId) => {
|
||||
try {
|
||||
const email = await client.getEmail(emailId);
|
||||
if (email) {
|
||||
set({ selectedEmail: email });
|
||||
}
|
||||
return email;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to fetch email content"
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
fetchQuota: async (client) => {
|
||||
try {
|
||||
const quota = await client.getQuota();
|
||||
set({ quota });
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch quota:', error);
|
||||
// Don't set error state as quota is optional
|
||||
}
|
||||
},
|
||||
|
||||
sendEmail: async (client, to, subject, body, cc, bcc) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await client.sendEmail(to, subject, body, cc, bcc);
|
||||
// Refresh emails after sending
|
||||
await get().fetchEmails(client);
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
isLoading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteEmail: async (client, emailId) => {
|
||||
try {
|
||||
// Get the email to check if it's unread and which mailboxes it belongs to
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
|
||||
await client.deleteEmail(emailId);
|
||||
|
||||
// Remove from local state and update mailbox counters if needed
|
||||
set((state) => {
|
||||
let updatedMailboxes = state.mailboxes;
|
||||
|
||||
// If the email was unread, decrement the unread counters
|
||||
if (isUnread && email.mailboxIds) {
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
unreadEmails: Math.max(0, mailbox.unreadEmails - 1),
|
||||
totalThreads: Math.max(0, mailbox.totalThreads - 1),
|
||||
unreadThreads: Math.max(0, mailbox.unreadThreads - 1)
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
} else if (email.mailboxIds) {
|
||||
// If email was read, only decrement total counters
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
totalThreads: Math.max(0, mailbox.totalThreads - 1)
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
emails: state.emails.filter(e => e.id !== emailId),
|
||||
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
|
||||
mailboxes: updatedMailboxes
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to delete email"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
markAsRead: async (client, emailId, read) => {
|
||||
try {
|
||||
// Check if this email is already being processed
|
||||
const processingKey = `${emailId}-${read}`;
|
||||
const currentProcessing = get().processingReadStatus;
|
||||
if (currentProcessing.has(processingKey)) {
|
||||
console.log(`Already processing markAsRead for ${emailId} to ${read}`);
|
||||
return; // Already being processed
|
||||
}
|
||||
|
||||
// Get the email to check its current state and mailboxes
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
// Check if already in the desired state
|
||||
const isCurrentlyRead = email.keywords?.$seen === true;
|
||||
if (isCurrentlyRead === read) {
|
||||
console.log(`Email ${emailId} already has $seen=${read}`);
|
||||
return; // Already in desired state
|
||||
}
|
||||
|
||||
// Add to processing set
|
||||
set((state) => ({
|
||||
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
|
||||
}));
|
||||
|
||||
await client.markAsRead(emailId, read);
|
||||
|
||||
// Update local state including mailbox counters
|
||||
set((state) => {
|
||||
// Remove from processing set
|
||||
const newProcessingSet = new Set(state.processingReadStatus);
|
||||
newProcessingSet.delete(processingKey);
|
||||
|
||||
// Only update counters if the state is actually changing
|
||||
const emailInState = state.emails.find(e => e.id === emailId);
|
||||
if (!emailInState) return { processingReadStatus: newProcessingSet };
|
||||
|
||||
const wasRead = emailInState.keywords?.$seen === true;
|
||||
if (wasRead === read) {
|
||||
console.log(`Email ${emailId} state unchanged, skipping counter update`);
|
||||
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
|
||||
}
|
||||
|
||||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
// Check if this email belongs to this mailbox
|
||||
if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
|
||||
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
|
||||
const delta = read ? -1 : 1;
|
||||
return {
|
||||
...mailbox,
|
||||
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
|
||||
unreadThreads: Math.max(0, mailbox.unreadThreads + delta)
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
|
||||
return {
|
||||
emails: state.emails.map(e =>
|
||||
e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
|
||||
),
|
||||
selectedEmail: state.selectedEmail?.id === emailId
|
||||
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
|
||||
: state.selectedEmail,
|
||||
mailboxes: updatedMailboxes,
|
||||
processingReadStatus: newProcessingSet
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// Remove from processing set on error
|
||||
set((state) => {
|
||||
const newProcessingSet = new Set(state.processingReadStatus);
|
||||
newProcessingSet.delete(`${emailId}-${read}`);
|
||||
return {
|
||||
processingReadStatus: newProcessingSet,
|
||||
error: error instanceof Error ? error.message : "Failed to update email"
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
moveToMailbox: async (client, emailId, destinationMailboxId) => {
|
||||
try {
|
||||
// Get the email to check its current mailboxes and read status
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
|
||||
|
||||
await client.moveEmail(emailId, destinationMailboxId);
|
||||
|
||||
// Update local state and mailbox counters
|
||||
set((state) => {
|
||||
// Update mailbox counters
|
||||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
// Remove from current mailboxes
|
||||
if (currentMailboxIds.includes(mailbox.id)) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
|
||||
totalThreads: Math.max(0, mailbox.totalThreads - 1),
|
||||
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
// Add to destination mailbox
|
||||
else if (mailbox.id === destinationMailboxId) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: mailbox.totalEmails + 1,
|
||||
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
totalThreads: mailbox.totalThreads + 1,
|
||||
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
});
|
||||
|
||||
// Update the email's mailboxIds
|
||||
const updatedEmails = state.emails.map(e => {
|
||||
if (e.id === emailId) {
|
||||
return {
|
||||
...e,
|
||||
mailboxIds: { [destinationMailboxId]: true }
|
||||
};
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
// If moved email is selected, update selectedEmail too
|
||||
const updatedSelectedEmail = state.selectedEmail?.id === emailId
|
||||
? { ...state.selectedEmail, mailboxIds: { [destinationMailboxId]: true } }
|
||||
: state.selectedEmail;
|
||||
|
||||
return {
|
||||
emails: updatedEmails,
|
||||
selectedEmail: updatedSelectedEmail,
|
||||
mailboxes: updatedMailboxes
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to move email"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
searchEmails: async (client, query) => {
|
||||
set({ isLoading: true, error: null, searchQuery: query, emails: [] }); // Clear emails for loading state
|
||||
try {
|
||||
const emails = await client.searchEmails(query);
|
||||
set({ emails, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||
isLoading: false,
|
||||
emails: []
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
toggleStar: async (client, emailId) => {
|
||||
try {
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const isFlagged = email.keywords.$flagged || false;
|
||||
await client.toggleStar(emailId, !isFlagged);
|
||||
|
||||
// Update local state
|
||||
set((state) => ({
|
||||
emails: state.emails.map(e =>
|
||||
e.id === emailId ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e
|
||||
),
|
||||
selectedEmail: state.selectedEmail?.id === emailId
|
||||
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } }
|
||||
: state.selectedEmail
|
||||
}));
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to update star"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadMockData: () => {
|
||||
const mockEmails: Email[] = [
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface LocaleStore {
|
||||
locale: string;
|
||||
setLocale: (locale: string) => void;
|
||||
}
|
||||
|
||||
export const useLocaleStore = create<LocaleStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
locale: 'en',
|
||||
setLocale: (locale) => set({ locale }),
|
||||
}),
|
||||
{
|
||||
name: 'locale-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
initializeTheme: () => void;
|
||||
}
|
||||
|
||||
const getSystemTheme = (): 'light' | 'dark' => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const applyTheme = (theme: 'light' | 'dark') => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
resolvedTheme: 'light',
|
||||
|
||||
setTheme: (theme) => {
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ theme, resolvedTheme });
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
const { theme } = get();
|
||||
const nextTheme: Theme =
|
||||
theme === 'light' ? 'dark' :
|
||||
theme === 'dark' ? 'system' : 'light';
|
||||
get().setTheme(nextTheme);
|
||||
},
|
||||
|
||||
initializeTheme: () => {
|
||||
const { theme } = get();
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ resolvedTheme });
|
||||
|
||||
// Listen for system theme changes
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
const { theme } = get();
|
||||
if (theme === 'system') {
|
||||
const newResolvedTheme = getSystemTheme();
|
||||
applyTheme(newResolvedTheme);
|
||||
set({ resolvedTheme: newResolvedTheme });
|
||||
}
|
||||
};
|
||||
|
||||
// Modern browsers
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
mediaQuery.addListener(handleChange);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'theme-storage',
|
||||
partialize: (state) => ({ theme: state.theme }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { create } from "zustand";
|
||||
import { Toast, ToastType } from "@/components/ui/toast";
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
|
||||
addToast: (toast) => {
|
||||
const id = Math.random().toString(36).substring(2, 11);
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
duration: toast.duration ?? 5000, // Default 5 seconds
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, newToast],
|
||||
}));
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((toast) => toast.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearToasts: () => {
|
||||
set({ toasts: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper functions for common toast types
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "success", title, message });
|
||||
},
|
||||
error: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "error", title, message, duration: 10000 });
|
||||
},
|
||||
info: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "info", title, message });
|
||||
},
|
||||
warning: (title: string, message?: string) => {
|
||||
useToastStore.getState().addToast({ type: "warning", title, message });
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
|
||||
Reference in New Issue
Block a user