From 04ab18d4e5de27053f52ba9e66858263d705f220 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Thu, 2 Oct 2025 01:40:37 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 46 ++ TODO.md | 187 +++++ app/[locale]/layout.tsx | 55 ++ app/[locale]/login/page.tsx | 150 ++++ app/[locale]/page.tsx | 320 ++++++++ app/globals.css | 226 +++++- app/layout.tsx | 39 +- app/page.tsx | 112 --- components/email/email-composer.tsx | 70 +- components/email/email-list-item.tsx | 129 +++- components/email/email-list.tsx | 48 +- components/email/email-viewer.tsx | 954 ++++++++++++++++++++++-- components/layout/sidebar.tsx | 365 +++++++-- components/providers/theme-provider.tsx | 14 + components/ui/avatar.tsx | 54 ++ components/ui/button.tsx | 10 +- components/ui/input.tsx | 9 +- components/ui/language-switcher.tsx | 46 ++ components/ui/toast.tsx | 80 ++ i18n.ts | 24 + lib/email-headers.ts | 203 +++++ lib/jmap/client.ts | 639 ++++++++++++---- lib/jmap/types.ts | 38 + lib/utils.ts | 78 ++ locales/en/common.json | 197 +++++ locales/fr/common.json | 197 +++++ middleware.ts | 14 + next.config.js | 4 + package-lock.json | 163 +++- package.json | 3 + stores/auth-store.ts | 118 +++ stores/email-store.ts | 342 ++++++++- stores/locale-store.ts | 19 + stores/theme-store.ts | 83 +++ stores/toast-store.ts | 52 ++ tailwind.config.ts | 1 + 36 files changed, 4647 insertions(+), 442 deletions(-) create mode 100644 CLAUDE.md create mode 100644 TODO.md create mode 100644 app/[locale]/layout.tsx create mode 100644 app/[locale]/login/page.tsx create mode 100644 app/[locale]/page.tsx delete mode 100644 app/page.tsx create mode 100644 components/providers/theme-provider.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/language-switcher.tsx create mode 100644 components/ui/toast.tsx create mode 100644 i18n.ts create mode 100644 lib/email-headers.ts create mode 100644 locales/en/common.json create mode 100644 locales/fr/common.json create mode 100644 middleware.ts create mode 100644 next.config.js create mode 100644 stores/auth-store.ts create mode 100644 stores/locale-store.ts create mode 100644 stores/theme-store.ts create mode 100644 stores/toast-store.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d2a2843 --- /dev/null +++ b/CLAUDE.md @@ -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
{t('key')}
; + ``` + +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 \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e96aae6 --- /dev/null +++ b/TODO.md @@ -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) + diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..df8594f --- /dev/null +++ b/app/[locale]/layout.tsx @@ -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 ( + + + + + {children} + + + + + ); +} diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx new file mode 100644 index 0000000..6bf5453 --- /dev/null +++ b/app/[locale]/login/page.tsx @@ -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 ( +
+
+
+ {/* Header */} +
+
+ +
+

{t("title")}

+

{t("subtitle")}

+
+ + {/* Error Message */} + {error && ( +
+ +
+ {error && (t(`error.${error}`) || t("error.generic"))} +
+
+ )} + + {/* Login Form */} +
+
+ +
+ + setFormData({ ...formData, serverUrl: e.target.value })} + className="pl-9" + placeholder={t("server_placeholder")} + required + /> +
+
+ +
+ +
+ + setFormData({ ...formData, username: e.target.value })} + className="pl-9" + placeholder={t("username_placeholder")} + required + autoComplete="username" + /> +
+
+ +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + className="pl-9" + placeholder={t("password_placeholder")} + required + autoComplete="current-password" + /> +
+
+ + +
+ + {/* Footer */} +
+

+ {t("secure_connection")} +

+

+ {t("security_note")} +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx new file mode 100644 index 0000000..10fdad6 --- /dev/null +++ b/app/[locale]/page.tsx @@ -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 ( +
+
+
+

{t("loading")}

+
+
+ ); + } + + return ( +
+ {/* Sidebar */} + { + setComposerMode('compose'); + setShowComposer(true); + }} + onLogout={handleLogout} + onSearch={handleSearch} + quota={quota} + /> + + {/* Email List */} +
+ { + 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" + /> +
+ + {/* Email Viewer */} + { + if (client) { + await markAsRead(client, emailId, read); + } + }} + onDownloadAttachment={handleDownloadAttachment} + /> + + {/* Email Composer Modal */} + {showComposer && ( +
+
+ { + setShowComposer(false); + setComposerMode('compose'); + }} + /> +
+
+ )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index 089e792..9648b54 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/app/layout.tsx b/app/layout.tsx index 52ad0e4..ec7324f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - - {children} - - - ); -} +// 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; +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 105b00b..0000000 --- a/app/page.tsx +++ /dev/null @@ -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 ( -
- {/* Sidebar */} - setShowComposer(true)} - /> - - {/* Email List */} -
- -
- - {/* Email Viewer */} - - - {/* Email Composer Modal */} - {showComposer && ( -
-
- setShowComposer(false)} - /> -
-
- )} -
- ); -} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d973be4..aeb6f49 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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 = () => { diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 0854a42..6e95f00 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -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 | 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 (
-
-
- - {sender?.name || sender?.email || "Unknown"} - - {isStarred && } - {email.hasAttachment && } - - {formatDate(email.receivedAt)} - -
-
- +
+ {/* Unread indicator */} + {isUnread && ( +
+ +
+ )} + + {/* Avatar */} + + + {/* Content */} +
+ {/* First Line: Sender and Date */} +
+
+ + {sender?.name || sender?.email || "Unknown"} + +
+ {isStarred && ( + + )} + {isImportant && ( + + Important + + )} + {email.hasAttachment && ( + + )} +
+
+ + {formatDate(email.receivedAt)} + +
+ + {/* Second Line: Subject */} +
{email.subject || "(no subject)"} - +
+ + {/* Third Line: Preview */} +

+ {email.preview || "No preview available"} +

-

- {truncateText(email.preview || "", 100)} -

); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 20d5e5e..539655a 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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 = () => ( +
+ {[...Array(8)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + return ( -
-
- {emails.length === 0 ? ( -
-

No emails to display

+
+ {/* List Header */} +
+

+ {isLoading ? 'Loading...' : emails.length > 0 ? `${emails.length} conversations` : 'No conversations'} +

+
+ + {/* Email List */} +
+ {isLoading ? ( + + ) : emails.length === 0 ? ( +
+ +

No emails in this mailbox

+

New messages will appear here

) : ( emails.map((email) => ( diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e7eda0d..544269c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1,8 +1,19 @@ "use client"; +import { useState, useEffect, useMemo } from "react"; +import DOMPurify from "dompurify"; import { Email } from "@/lib/jmap/types"; import { Button } from "@/components/ui/button"; -import { formatDate } from "@/lib/utils"; +import { Avatar } from "@/components/ui/avatar"; +import { formatDate, formatFileSize, cn } from "@/lib/utils"; +import { + parseAuthenticationResults, + parseSpamScore, + parseReceivedHeaders, + getSecurityStatus, + extractListHeaders, + formatBytes +} from "@/lib/email-headers"; import { Reply, ReplyAll, @@ -11,6 +22,35 @@ import { Archive, Star, MoreVertical, + ChevronDown, + ChevronUp, + Download, + Paperclip, + Mail, + Clock, + Loader2, + AlertCircle, + ExternalLink, + Printer, + FileText, + FileImage, + FileVideo, + FileAudio, + FileArchive, + File, + Shield, + Image, + Circle, + X, + Check, + AlertTriangle, + Minus, + ShieldCheck, + ShieldAlert, + ShieldOff, + Network, + Hash, + List, } from "lucide-react"; interface EmailViewerProps { @@ -21,8 +61,58 @@ interface EmailViewerProps { onDelete?: () => void; onArchive?: () => void; onToggleStar?: () => void; + onMarkAsRead?: (emailId: string, read: boolean) => void; + onSetColorTag?: (emailId: string, color: string | null) => void; + onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; } +// Helper function to get file icon based on mime type or extension +const getFileIcon = (name?: string, type?: string) => { + const ext = name?.split('.').pop()?.toLowerCase(); + const mimeType = type?.toLowerCase(); + + if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) { + return FileImage; + } + if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) { + return FileVideo; + } + if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) { + return FileAudio; + } + if (mimeType === 'application/pdf' || ext === 'pdf') { + return FileText; + } + if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) { + return FileArchive; + } + if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext || '')) { + return FileText; + } + return File; +}; + +// Color options for email tags +const colorOptions = [ + { name: "Red", value: "red", color: "bg-red-500" }, + { name: "Orange", value: "orange", color: "bg-orange-500" }, + { name: "Yellow", value: "yellow", color: "bg-yellow-500" }, + { name: "Green", value: "green", color: "bg-green-500" }, + { name: "Blue", value: "blue", color: "bg-blue-500" }, + { name: "Purple", value: "purple", color: "bg-purple-500" }, + { name: "Pink", value: "pink", color: "bg-pink-500" }, +]; + +const getCurrentColor = (keywords: Record | undefined) => { + if (!keywords) return null; + for (const key of Object.keys(keywords)) { + if (key.startsWith("$color:") && keywords[key] === true) { + return key.replace("$color:", ""); + } + } + return null; +}; + export function EmailViewer({ email, onReply, @@ -31,76 +121,838 @@ export function EmailViewer({ onDelete, onArchive, onToggleStar, + onMarkAsRead, + onSetColorTag, + onDownloadAttachment, }: EmailViewerProps) { + const [showFullHeaders, setShowFullHeaders] = useState(false); + const [isLoadingContent, setIsLoadingContent] = useState(false); + const [allowExternalContent, setAllowExternalContent] = useState(false); + const [hasBlockedContent, setHasBlockedContent] = useState(false); + const [isLoadingEmail, setIsLoadingEmail] = useState(false); + const currentColor = getCurrentColor(email?.keywords); + + useEffect(() => { + // Mark as read when email is viewed + if (email && !email.keywords?.$seen && onMarkAsRead) { + onMarkAsRead(email.id, true); + } + }, [email?.id, email?.keywords?.$seen, onMarkAsRead]); + + // Reset external content permission when email changes + useEffect(() => { + setAllowExternalContent(false); + setHasBlockedContent(false); + }, [email?.id]); + + // Sanitize and prepare email HTML content + const emailContent = useMemo(() => { + if (!email) return { html: "", isHtml: false }; + + setIsLoadingContent(false); + + // Check if we have body values + if (email.bodyValues) { + // Check if HTML content exists and if it's actually rich HTML or just plain text wrapper + let useHtmlVersion = false; + let htmlContent = ''; + + if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { + htmlContent = email.bodyValues[email.htmlBody[0].partId].value; + + // Check if HTML is just a minimal wrapper around plain text + // by checking if it lacks common HTML formatting elements + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlContent; + const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote'); + const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2; + const hasBrTags = tempDiv.querySelectorAll('br').length > 0; + + // Use HTML if it has rich formatting, multiple paragraphs, or explicit line breaks + useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags); + } + + // If we should use HTML version and it exists + if (useHtmlVersion && htmlContent) { + // Create a custom DOMPurify hook to handle external content + let blockedExternalContent = false; + + const sanitizeConfig: any = { + ADD_TAGS: ['style'], + ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], + ALLOW_DATA_ATTR: false, + FORCE_BODY: true, + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'], + }; + + // If external content is not allowed, block images and external resources + if (!allowExternalContent) { + sanitizeConfig.FORBID_TAGS.push('link'); + sanitizeConfig.FORBID_ATTR.push('background'); + + // Hook to modify src attributes + DOMPurify.addHook('afterSanitizeAttributes', (node: any) => { + // Block external images + if (node.tagName === 'IMG') { + const src = node.getAttribute('src'); + if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { + node.setAttribute('data-blocked-src', src); + // Use a subtle transparent placeholder + node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4='); + node.setAttribute('alt', ''); + node.style.display = 'none'; // Hide blocked images completely for cleaner look + blockedExternalContent = true; + } + } + + // Block external stylesheets and resources in style attributes + if (node.style) { + const style = node.style.cssText; + if (style && style.includes('url(')) { + const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi); + if (urlMatch) { + node.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()'); + blockedExternalContent = true; + } + } + } + }); + } + + // Sanitize HTML to prevent XSS + const cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig); + + // Remove the hook after sanitization + DOMPurify.removeAllHooks(); + + // Update blocked content state + if (blockedExternalContent && !hasBlockedContent) { + setHasBlockedContent(true); + } + + return { + html: cleanHtml, + isHtml: true + }; + } + + // Use text content if available (either as fallback or when HTML is minimal) + if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { + const textContent = email.bodyValues[email.textBody[0].partId].value; + + // Convert plain text to HTML with proper formatting + const htmlFromText = textContent + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\r\n/g, '
') // Windows line endings + .replace(/\r/g, '
') // Old Mac line endings + .replace(/\n/g, '
') // Unix line endings + .replace(/\t/g, '    ') // Convert tabs to spaces + .replace(/(https?:\/\/[^\s<]+)/g, '$1'); // Don't match across tags + + return { + html: htmlFromText, + isHtml: false + }; + } + } + + // Last resort: use preview + if (email.preview) { + setIsLoadingContent(true); // Show that we're using limited content + return { + html: `

Loading full email content...

+
${email.preview}
`, + isHtml: false + }; + } + + return { + html: '

No content available

', + isHtml: false + }; + }, [email, allowExternalContent, hasBlockedContent]); + if (!email) { return ( -
-

Select an email to view

+
+
+
+ +
+

No conversation selected

+

Choose a conversation from the list to read it here

+
); } const sender = email.from?.[0]; const isStarred = email.keywords?.$flagged; + const isImportant = email.keywords?.["$important"]; return ( -
- {/* Email Header */} -
-
-
-

- {email.subject || "(no subject)"} -

-
- From: {sender?.name || sender?.email} - {formatDate(email.receivedAt)} -
- {email.to && email.to.length > 0 && ( -
- To: {email.to.map(addr => addr.name || addr.email).join(", ")} +
+ {/* Modern Header Section */} +
+ {/* Subject Bar */} +
+
+
+

+ {email.subject || "(no subject)"} +

+
+ + + {new Date(email.receivedAt).toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + + {email.hasAttachment && ( + + + Attachments + + )} + {isImportant && ( + + Important + + )}
- )} +
+ + {/* Quick Actions */} +
+ {/* Primary Reply Button */} + + + {/* Reply Options Dropdown */} +
+ +
+ + +
+
+ +
+ + + + + +
+ + {/* Compact Dynamic Color Picker */} +
+ + + {/* Colors appear on hover */} +
+
+ {colorOptions.map((option) => ( + + )} +
+
+
+ + +
- {/* Action Buttons */} -
- - - -
- - - - + {/* Sender Info */} +
+
+ + +
+
+ + {sender?.name || sender?.email || "Unknown"} + + {sender?.email && sender?.name && ( + + <{sender.email}> + + )} +
+ +
+ {email.to && email.to.length > 0 && ( +
+ To: + + {email.to.slice(0, 2).map(r => r.name || r.email).join(", ")} + {email.to.length > 2 && ( + + )} + +
+ )} + + {(email.cc && email.cc.length > 0) && ( +
+ CC: + + {email.cc.map(r => r.name || r.email).join(", ")} + +
+ )} +
+ + {/* Modern Expandable Details */} + {showFullHeaders && ( +
+ {/* Security & Authentication Section */} + {(email.authenticationResults || email.spamScore !== undefined) && ( +
+
+

+ + Security & Authentication +

+
+
+ {/* Authentication Results */} + {email.authenticationResults && ( +
+ {/* SPF Check */} + {email.authenticationResults.spf && ( +
+
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' && + } + {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' && + } + {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' && + } + {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' && + } +
+
SPF
+
+ {email.authenticationResults.spf.result} +
+
+
+ {email.authenticationResults.spf.domain && ( +
+ {email.authenticationResults.spf.domain} +
+ )} +
+ )} + + {/* DKIM Check */} + {email.authenticationResults.dkim && ( +
+
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' && + } + {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' && + } + {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' && + } + {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' && + } +
+
DKIM
+
+ {email.authenticationResults.dkim.result} +
+
+
+ {email.authenticationResults.dkim.domain && ( +
+ {email.authenticationResults.dkim.domain} +
+ )} +
+ )} + + {/* DMARC Check */} + {email.authenticationResults.dmarc && ( +
+
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' && + } + {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' && + } + {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' && + } + {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' && + } +
+
DMARC
+
+ {email.authenticationResults.dmarc.result} +
+
+
+ {email.authenticationResults.dmarc.policy && ( +
+ Policy: {email.authenticationResults.dmarc.policy} +
+ )} +
+ )} + + {/* Spam Score */} + {email.spamScore !== undefined && ( +
5 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800" : + email.spamScore > 2 ? "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800" : + "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800" + )}> +
+ 5 ? "text-red-600 dark:text-red-400" : + email.spamScore > 2 ? "text-amber-600 dark:text-amber-400" : + "text-green-600 dark:text-green-400" + )} /> +
+
Spam Score
+
5 ? "text-red-600 dark:text-red-400" : + email.spamScore > 2 ? "text-amber-600 dark:text-amber-400" : + "text-green-600 dark:text-green-400" + )}> + {email.spamScore.toFixed(1)} +
+
+
+ {email.spamStatus && ( +
+ {email.spamStatus} +
+ )} +
+ )} +
+ )} +
+
+ )} + + {/* Technical Details Section - Only show if we have useful technical info */} + {(email.messageId || email.replyTo?.length || (email.sentAt && email.receivedAt && + Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000)) && ( +
+
+

+ + Technical Details +

+
+
+
+ {/* Message ID */} + {email.messageId && ( +
+ +
+ Message-ID: +
+ {email.messageId} +
+
+
+ )} + + {/* Reply-To if different from sender */} + {email.replyTo && email.replyTo.length > 0 && + (!email.from || email.replyTo[0].email !== email.from[0]?.email) && ( +
+ +
+ Reply-To: +
+ {email.replyTo.map((recipient, i) => ( + + {recipient.name && {recipient.name}} + {recipient.email} + + ))} +
+
+
+ )} + + {/* Time delay if significant (>1 minute difference) */} + {email.sentAt && email.receivedAt && + Math.abs(new Date(email.sentAt).getTime() - new Date(email.receivedAt).getTime()) > 60000 && ( +
+ +
+ Delivery time: +
+ {(() => { + const diff = Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + if (days > 0) return `${days} day${days > 1 ? 's' : ''} ${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}`; + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ${minutes % 60} minute${minutes % 60 !== 1 ? 's' : ''}`; + return `${minutes} minute${minutes > 1 ? 's' : ''}`; + })()} +
+
+
+ )} + + {/* Part of conversation */} + {email.references && email.references.length > 0 && ( +
+ +
+ Part of conversation: +
+ {email.references.length} previous message{email.references.length > 1 ? 's' : ''} in this thread +
+
+
+ )} +
+
+
+ )} +
+ )} + + +
+
- {/* Email Body */} -
-
- {email.preview && ( -
+ {/* Email Content Area */} +
+ {/* Ultra Minimalist External Content Banner */} + {hasBlockedContent && !allowExternalContent && ( +
+
+ +
+
+ )} + +
+ + {/* Inline Attachments */} + {email.attachments && email.attachments.length > 0 && ( +
+ {/* Image attachments as thumbnails */} + {email.attachments.filter(a => + a.type?.startsWith('image/') || + ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') + ).length > 0 && ( +
+
+ {email.attachments + .filter(a => + a.type?.startsWith('image/') || + ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') + ) + .map((attachment, i) => ( +
{ + if (attachment.blobId && onDownloadAttachment) { + onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); + } + }} + > +
+
+ +
+
+
+ +
+
+ {attachment.name} +
+
+ ))} +
+
+ )} + + {/* Non-image attachments in a compact list */} + {email.attachments.filter(a => + !a.type?.startsWith('image/') && + !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') + ).length > 0 && ( +
+ + {email.attachments + .filter(a => + !a.type?.startsWith('image/') && + !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '') + ) + .map((attachment, i) => { + const FileIcon = getFileIcon(attachment.name, attachment.type); + return ( + + ); + })} +
+ )} +
)} + + {/* Email Body */} +
+ {isLoadingContent && ( +
+ + Loading full email content... +
+ )} + +
+ {emailContent.isHtml ? ( +
+ ) : ( +
+ )} +
+
+ + {/* Quick Reply Section */} +
+
+ +
+ { + e.target.placeholder = "Type your reply..."; + e.target.parentElement?.parentElement?.classList.add('focused'); + }} + onBlur={(e) => { + e.target.placeholder = "Reply..."; + if (!e.target.value) { + e.target.parentElement?.parentElement?.classList.remove('focused'); + } + }} + /> +
+ +
+
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 2d1ccb6..9c9e93b 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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; + 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 ( + <> +
+ {/* Expand/Collapse Chevron */} + {hasChildren && ( + + )} + + {/* Mailbox Button */} + +
+ + {/* Render children if expanded */} + {hasChildren && isExpanded && !isCollapsed && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} + + ); +} 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>(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 (
{/* Header */} -
+
)}
@@ -72,51 +292,112 @@ export function Sidebar({ {/* Search */} {!isCollapsed && (
-
- +
+ setSearchQuery(e.target.value)} className="pl-9" /> -
+
)} {/* Mailbox List */}
-
- {defaultMailboxes.map((mailbox) => ( - - ))} +
+ {mailboxes.length === 0 ? ( +
+ {!isCollapsed && t("loading_mailboxes")} +
+ ) : ( + <> + {/* Render hierarchical mailbox tree */} + {mailboxTree.map((node) => ( + + ))} + + )}
{/* Footer */} {!isCollapsed && ( -
-
Storage: 2.5 GB / 15 GB
+
+ {quota && quota.total > 0 && ( +
+ {t("storage")}: {formatFileSize(quota.used)} / {formatFileSize(quota.total)} +
+ )} + + {/* Language Switcher */} +
+ +
+ + {/* Theme Toggle */} +
+
+ + + +
+
+ + {onLogout && ( + + )}
)}
diff --git a/components/providers/theme-provider.tsx b/components/providers/theme-provider.tsx new file mode 100644 index 0000000..33596c3 --- /dev/null +++ b/components/providers/theme-provider.tsx @@ -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}; +} \ No newline at end of file diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..1f069e9 --- /dev/null +++ b/components/ui/avatar.tsx @@ -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 ( +
+ {getInitials()} +
+ ); +} \ No newline at end of file diff --git a/components/ui/button.tsx b/components/ui/button.tsx index d465589..3ac9b70 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -12,15 +12,15 @@ const Button = React.forwardRef( return ( + ))} +
+ ); +} \ No newline at end of file diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 0000000..039687a --- /dev/null +++ b/components/ui/toast.tsx @@ -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 ( +
+ +
+

{toast.title}

+ {toast.message && ( +

{toast.message}

+ )} +
+ +
+ ); +} + +export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) { + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/i18n.ts b/i18n.ts new file mode 100644 index 0000000..7522ed9 --- /dev/null +++ b/i18n.ts @@ -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() + }; +}); \ No newline at end of file diff --git a/lib/email-headers.ts b/lib/email-headers.ts new file mode 100644 index 0000000..9fccf61 --- /dev/null +++ b/lib/email-headers.ts @@ -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): { + 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 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; +} \ No newline at end of file diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2f39914..3236a26 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { - 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 { + 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 { - 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 { - 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 { - 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 { - 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 { - 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 { + await this.request([ ["Email/set", { - accountId, + accountId: this.accountId, update: { [emailId]: { - mailboxIds: { - [toMailboxId]: true, - }, + "keywords/$flagged": starred, + }, + }, + }, "0"], + ]); + } + + async updateEmailKeywords(emailId: string, keywords: Record): Promise { + await this.request([ + ["Email/set", { + accountId: this.accountId, + update: { + [emailId]: { + keywords, }, }, }, "0"], @@ -122,48 +403,71 @@ export class JMAPEmailClient { } async deleteEmail(emailId: string): Promise { - 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 { - 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 { + 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 { + 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 { - 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, "
") }], - }; - - // 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 { + 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; + } + } } \ No newline at end of file diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index ab759b7..6edd456 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -15,8 +15,46 @@ export interface Email { preview?: string; textBody?: EmailBodyPart[]; htmlBody?: EmailBodyPart[]; + bodyValues?: Record; attachments?: Attachment[]; hasAttachment: boolean; + // Extended header information + messageId?: string; + inReplyTo?: string[]; + references?: string[]; + headers?: Record; + // 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 { diff --git a/lib/utils.ts b/lib/utils.ts index 13ab9f4..8cbe955 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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(); + 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; } \ No newline at end of file diff --git a/locales/en/common.json b/locales/en/common.json new file mode 100644 index 0000000..6d5304a --- /dev/null +++ b/locales/en/common.json @@ -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" + } +} \ No newline at end of file diff --git a/locales/fr/common.json b/locales/fr/common.json new file mode 100644 index 0000000..171b769 --- /dev/null +++ b/locales/fr/common.json @@ -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" + } +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..9ee0f8d --- /dev/null +++ b/middleware.ts @@ -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|.*\\..*).*)'] +}; \ No newline at end of file diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..7ecfb8f --- /dev/null +++ b/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = nextConfig; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 13e2094..c96f023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 236b073..77589ed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/stores/auth-store.ts b/stores/auth-store.ts new file mode 100644 index 0000000..bc6177a --- /dev/null +++ b/stores/auth-store.ts @@ -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; + logout: () => void; + checkAuth: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create()( + 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 + }), + } + ) +); \ No newline at end of file diff --git a/stores/email-store.ts b/stores/email-store.ts index 6241b95..cb5ef35 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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; // 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; + fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise; + fetchEmailContent: (client: JMAPClient, emailId: string) => Promise; + fetchQuota: (client: JMAPClient) => Promise; + sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[]) => Promise; + deleteEmail: (client: JMAPClient, emailId: string) => Promise; + markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise; + moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise; + searchEmails: (client: JMAPClient, query: string) => Promise; + toggleStar: (client: JMAPClient, emailId: string) => Promise; // Mock data for demo loadMockData: () => void; } -export const useEmailStore = create((set) => ({ +export const useEmailStore = create((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((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[] = [ diff --git a/stores/locale-store.ts b/stores/locale-store.ts new file mode 100644 index 0000000..7cc5e96 --- /dev/null +++ b/stores/locale-store.ts @@ -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()( + persist( + (set) => ({ + locale: 'en', + setLocale: (locale) => set({ locale }), + }), + { + name: 'locale-storage', + } + ) +); \ No newline at end of file diff --git a/stores/theme-store.ts b/stores/theme-store.ts new file mode 100644 index 0000000..1c27a40 --- /dev/null +++ b/stores/theme-store.ts @@ -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()( + 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 }), + } + ) +); \ No newline at end of file diff --git a/stores/toast-store.ts b/stores/toast-store.ts new file mode 100644 index 0000000..b151b26 --- /dev/null +++ b/stores/toast-store.ts @@ -0,0 +1,52 @@ +import { create } from "zustand"; +import { Toast, ToastType } from "@/components/ui/toast"; + +interface ToastStore { + toasts: Toast[]; + addToast: (toast: Omit) => void; + removeToast: (id: string) => void; + clearToasts: () => void; +} + +export const useToastStore = create((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 }); + }, +}; \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index 7848dc7..d4e90b6 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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}",