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:
Matthieu MALVACHE
2025-10-02 01:40:37 +02:00
parent c9769264e2
commit 04ab18d4e5
36 changed files with 4647 additions and 442 deletions
+46
View File
@@ -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
+187
View File
@@ -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)
+55
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+320
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+64 -6
View File
@@ -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 = () => {
+108 -21
View File
@@ -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>
);
+42 -6
View File
@@ -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
View File
@@ -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>
+14
View File
@@ -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}</>;
}
+54
View File
@@ -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>
);
}
+5 -5
View File
@@ -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",
+6 -3
View File
@@ -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
)}
+46
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+24
View File
@@ -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()
};
});
+203
View File
@@ -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
View File
@@ -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;
}
}
}
+38
View File
@@ -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 {
+78
View File
@@ -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;
}
+197
View File
@@ -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"
}
}
+197
View File
@@ -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"
}
}
+14
View File
@@ -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|.*\\..*).*)']
};
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;
+162 -1
View File
@@ -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",
+3
View File
@@ -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",
+118
View File
@@ -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
View File
@@ -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[] = [
+19
View File
@@ -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',
}
)
);
+83
View File
@@ -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 }),
}
)
);
+52
View File
@@ -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
View File
@@ -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}",