diff --git a/TODO.md b/TODO.md index 6c08a19..1782578 100644 --- a/TODO.md +++ b/TODO.md @@ -87,11 +87,11 @@ - [ ] Add email threading support ### Real-time Updates -- [ ] Set up EventSource for JMAP push notifications (getEventSourceUrl() exists but unused) -- [ ] Implement state synchronization -- [ ] Handle email arrival notifications -- [ ] Update unread counts in real-time -- [ ] Handle mailbox changes +- [x] Set up EventSource for JMAP push notifications +- [x] Implement state synchronization +- [x] Handle email arrival notifications +- [x] Update unread counts in real-time +- [x] Handle mailbox changes ### UI Enhancements - [x] Improve mailbox subfolder UI/UX with hierarchical display @@ -117,7 +117,7 @@ - [x] Add loading states and skeletons - [x] Add smooth email loading transitions with cross-fade effect - [x] Implement functional quick reply with auto-expand and direct send -- [ ] Implement error boundaries +- [x] Implement error boundaries - [x] Create settings page - [x] Integrate mark-as-read delay setting in email viewer - [x] Integrate delete action setting (trash vs permanent) @@ -231,7 +231,7 @@ All settings are now properly wired to their functionality: ### Feature Completeness - **Authentication**: ✅ Complete (secure design, no password storage) - **Email Operations**: ✅ Complete except threading -- **Real-time Updates**: ❌ Not started (infrastructure exists) +- **Real-time Updates**: ✅ Complete (EventSource push, toast notifications, status indicator) - **UI Enhancements**: ✅ Settings fully integrated - **Contacts/Address Book**: ❌ Not started - **Security**: ⚠️ Client-side done, server headers needed diff --git a/app/[locale]/error.tsx b/app/[locale]/error.tsx new file mode 100644 index 0000000..b5c7ae6 --- /dev/null +++ b/app/[locale]/error.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { AlertCircle, RefreshCw, Home } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useParams, useRouter } from "next/navigation"; + +/** + * Route-level error boundary for locale pages. + * Catches errors in the locale layout and its children. + */ +export default function LocaleError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations("errors"); + const params = useParams(); + const router = useRouter(); + + useEffect(() => { + console.error("Route error:", error); + }, [error]); + + return ( +
+
+
+ +
+

+ {t("page_error_title")} +

+

+ {t("page_error_description")} +

+
+ + +
+
+
+ ); +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 12c881f..fe8cd2d 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -37,7 +37,7 @@ export default async function LocaleLayout({ let messages; try { messages = (await import(`@/locales/${locale}/common.json`)).default; - } catch (error) { + } catch { notFound(); } diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index c9b79ef..ccee597 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -41,7 +41,7 @@ export default function LoginPage() { try { const usernames = JSON.parse(saved); setSavedUsernames(usernames); - } catch (e) { + } catch { console.error("Failed to parse saved usernames"); } } @@ -55,7 +55,7 @@ export default function LoginPage() { if (saved) { try { usernames = JSON.parse(saved); - } catch (e) { + } catch { console.error("Failed to parse saved usernames"); } } diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 853ff60..6021d04 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -11,6 +11,13 @@ import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; import { debug } from "@/lib/debug"; +import { + ErrorBoundary, + SidebarErrorFallback, + EmailListErrorFallback, + EmailViewerErrorFallback, + ComposerErrorFallback, +} from "@/components/error"; export default function Home() { const router = useRouter(); @@ -27,6 +34,8 @@ export default function Home() { selectedEmail, selectedMailbox, quota, + isPushConnected, + newEmailNotification, selectEmail, selectMailbox, fetchMailboxes, @@ -41,8 +50,34 @@ export default function Home() { isLoading, isLoadingEmail, setLoadingEmail, + setPushConnected, + handleStateChange, + clearNewEmailNotification, + fetchEmailContent, } = useEmailStore(); + // Play notification sound for new emails + const playNotificationSound = () => { + try { + // Use Web Audio API for a simple notification beep + const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.value = 800; // Hz + oscillator.type = 'sine'; + gainNode.gain.value = 0.1; // Low volume + + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.15); // Short beep + } catch (e) { + debug.log('Could not play notification sound:', e); + } + }; + // Update page title based on context useEffect(() => { let title = "Webmail"; @@ -110,13 +145,39 @@ export default function Home() { } else { await fetchEmails(client); } + + // Setup push notifications after successful data load + try { + // Register state change callback + client.onStateChange((change) => handleStateChange(change, client)); + + // Start receiving push notifications + const pushEnabled = client.setupPushNotifications(); + + if (pushEnabled) { + setPushConnected(true); + debug.log('[Push] Push notifications successfully enabled'); + } else { + debug.log('[Push] Push notifications not available on this server'); + } + } catch (error) { + // Push notifications are optional - don't break the app if they fail + debug.log('[Push] Failed to setup push notifications:', error); + } } catch (error) { console.error('Error loading email data:', error); } }; loadData(); } - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota]); + + // Cleanup push notifications on unmount + return () => { + if (client) { + client.closePushNotifications(); + } + }; + }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]); // Handle mark-as-read with delay based on settings useEffect(() => { @@ -164,6 +225,15 @@ export default function Home() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmail?.id]); + // Handle new email notifications - play sound + useEffect(() => { + if (newEmailNotification) { + playNotificationSound(); + debug.log('New email received:', newEmailNotification.subject); + clearNewEmailNotification(); + } + }, [newEmailNotification, clearNewEmailNotification]); + const handleEmailSend = async (data: { to: string[]; cc: string[]; @@ -341,96 +411,111 @@ export default function Home() { return (
{/* Sidebar */} - { - setComposerMode('compose'); - setShowComposer(true); - }} - onLogout={handleLogout} - onSearch={handleSearch} - quota={quota} - /> + + { + setComposerMode('compose'); + setShowComposer(true); + }} + onLogout={handleLogout} + onSearch={handleSearch} + quota={quota} + isPushConnected={isPushConnected} + /> + {/* Email List */}
- { - if (!client || !email) return; + + { + if (!client || !email) return; - // Set loading state immediately (keep current email visible) - setLoadingEmail(true); + // Set loading state immediately (keep current email visible) + setLoadingEmail(true); - // Fetch the full content - try { - // Find selected mailbox to determine accountId (for shared folders) - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - // Only pass accountId for shared mailboxes - const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + // Fetch the full content + try { + // Find selected mailbox to determine accountId (for shared folders) + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + // Only pass accountId for shared mailboxes + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - const fullEmail = await client.getEmail(email.id, accountId); - if (fullEmail) { - selectEmail(fullEmail); - // Mark-as-read logic is now handled by useEffect + const fullEmail = await client.getEmail(email.id, accountId); + if (fullEmail) { + selectEmail(fullEmail); + // Mark-as-read logic is now handled by useEffect + } + } catch (error) { + console.error('Failed to fetch email content:', error); + } finally { + setLoadingEmail(false); } - } catch (error) { - console.error('Failed to fetch email content:', error); - } finally { - setLoadingEmail(false); - } - }} - className="h-full" - /> + }} + className="h-full" + /> +
{/* Email Viewer */} - { - if (client) { - await markAsRead(client, emailId, read); - } - }} - onDownloadAttachment={handleDownloadAttachment} - onQuickReply={handleQuickReply} - currentUserEmail={client?.["username"]} - currentUserName={client?.["username"]?.split("@")[0]} - /> + + { + if (client) { + await markAsRead(client, emailId, read); + } + }} + onDownloadAttachment={handleDownloadAttachment} + onQuickReply={handleQuickReply} + currentUserEmail={client?.["username"]} + currentUserName={client?.["username"]?.split("@")[0]} + /> + {/* Email Composer Modal */} {showComposer && (
- { + { setShowComposer(false); setComposerMode('compose'); }} - onDiscardDraft={handleDiscardDraft} - /> + > + { + setShowComposer(false); + setComposerMode('compose'); + }} + onDiscardDraft={handleDiscardDraft} + /> +
)} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..6353f69 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; + +/** + * Global error boundary for the root layout. + * Note: This component cannot use translations since it's outside providers. + * It must render its own and tags as it replaces the root layout. + */ +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Global error:", error); + }, [error]); + + return ( + + +
+
+
+ +
+

+ Something went wrong +

+

+ An unexpected error occurred. Please try again. +

+ +
+
+ + + ); +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 2a7db98..aaf4baa 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -232,6 +232,7 @@ export function EmailComposer({ clearTimeout(saveTimeoutRef.current); } }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up }, [to, cc, bcc, subject, body, attachments]); const handleSend = async () => { diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 3f16ee9..764945c 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -1,6 +1,6 @@ "use client"; -import { formatDate, truncateText } from "@/lib/utils"; +import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 6ee1ca1..1902d30 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -27,7 +27,6 @@ export function EmailList({ const { client } = useAuthStore(); const { selectedEmailIds, - toggleEmailSelection, selectAllEmails, clearSelection, batchMarkAsRead, @@ -35,7 +34,6 @@ export function EmailList({ loadMoreEmails, hasMoreEmails, isLoadingMore, - isLoading: storeIsLoading } = useEmailStore(); const [isProcessing, setIsProcessing] = useState(false); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f4091d2..65c670e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -5,15 +5,8 @@ import DOMPurify from "dompurify"; import { Email } from "@/lib/jmap/types"; import { Button } from "@/components/ui/button"; 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 { formatFileSize, cn } from "@/lib/utils"; +import { getSecurityStatus } from "@/lib/email-headers"; import { Reply, ReplyAll, @@ -29,8 +22,6 @@ import { Mail, Clock, Loader2, - AlertCircle, - ExternalLink, Printer, FileText, FileImage, @@ -47,7 +38,6 @@ import { Minus, ShieldCheck, ShieldAlert, - ShieldOff, Network, Hash, List, @@ -156,6 +146,7 @@ export function EmailViewer({ if (email && !email.keywords?.$seen && onMarkAsRead) { onMarkAsRead(email.id, true); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- email?.id changes when email changes, which is the intended trigger }, [email?.id, email?.keywords?.$seen, onMarkAsRead]); // Reset external content permission and quick reply when email changes diff --git a/components/error/error-boundary.tsx b/components/error/error-boundary.tsx new file mode 100644 index 0000000..cd088d3 --- /dev/null +++ b/components/error/error-boundary.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React, { Component, ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { debug } from "@/lib/debug"; + +export interface FallbackProps { + error: Error; + resetError: () => void; + t: (key: string) => string; +} + +interface ErrorBoundaryProps { + children: ReactNode; + fallback: (props: FallbackProps) => ReactNode; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; + onReset?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +/** + * Core error boundary class component (React requirement). + * Receives translation function as prop from the functional wrapper. + */ +class ErrorBoundaryCore extends Component< + ErrorBoundaryProps & { t: (key: string) => string }, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + // Always log errors + debug.error("[ErrorBoundary]", error.message, { + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + + // Call optional error handler + this.props.onError?.(error, errorInfo); + } + + resetError = (): void => { + this.props.onReset?.(); + this.setState({ hasError: false, error: null }); + }; + + render(): ReactNode { + if (this.state.hasError && this.state.error) { + return this.props.fallback({ + error: this.state.error, + resetError: this.resetError, + t: this.props.t, + }); + } + return this.props.children; + } +} + +/** + * Functional wrapper that injects translations into the error boundary. + * Use this component to wrap any part of your UI that might throw errors. + * + * @example + * + * + * + */ +export function ErrorBoundary({ + children, + fallback, + onError, + onReset, +}: ErrorBoundaryProps) { + const t = useTranslations("errors"); + + return ( + + {children} + + ); +} diff --git a/components/error/error-fallbacks.tsx b/components/error/error-fallbacks.tsx new file mode 100644 index 0000000..d635871 --- /dev/null +++ b/components/error/error-fallbacks.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { AlertCircle, RefreshCw, Inbox, Mail, Settings, FolderOpen } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { FallbackProps } from "./error-boundary"; + +/** + * Full-page error fallback for route-level errors. + */ +export function PageErrorFallback({ error, resetError, t }: FallbackProps) { + return ( +
+
+
+ +
+

+ {t("page_error_title")} +

+

+ {t("page_error_description")} +

+ +
+
+ ); +} + +/** + * Sidebar error fallback - matches sidebar width (256px). + */ +export function SidebarErrorFallback({ resetError, t }: FallbackProps) { + return ( +
+ +

+ {t("sidebar_error")} +

+ +
+ ); +} + +/** + * Email list error fallback - matches email list panel width (384px). + */ +export function EmailListErrorFallback({ resetError, t }: FallbackProps) { + return ( +
+ +

+ {t("email_list_error")} +

+ +
+ ); +} + +/** + * Email viewer error fallback - fills remaining space (flex-1). + */ +export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) { + return ( +
+
+ +
+

+ {t("viewer_error_title")} +

+

+ {t("viewer_error_description")} +

+ +
+ ); +} + +/** + * Email composer modal error fallback. + */ +export function ComposerErrorFallback({ resetError, t }: FallbackProps) { + return ( +
+ +

+ {t("composer_error")} +

+ +
+ ); +} + +/** + * Settings page error fallback. + */ +export function SettingsErrorFallback({ resetError, t }: FallbackProps) { + return ( +
+ +

+ {t("settings_error_title")} +

+

+ {t("settings_error_description")} +

+ +
+ ); +} diff --git a/components/error/index.ts b/components/error/index.ts new file mode 100644 index 0000000..2b70083 --- /dev/null +++ b/components/error/index.ts @@ -0,0 +1,10 @@ +export { ErrorBoundary } from "./error-boundary"; +export type { FallbackProps } from "./error-boundary"; +export { + PageErrorFallback, + SidebarErrorFallback, + EmailListErrorFallback, + EmailViewerErrorFallback, + ComposerErrorFallback, + SettingsErrorFallback, +} from "./error-fallbacks"; diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 0722a83..39edf01 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -2,10 +2,9 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; -import { useParams, useRouter, usePathname } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { LanguageSwitcher } from "@/components/ui/language-switcher"; import { Inbox, Send, @@ -19,13 +18,8 @@ import { LogOut, ChevronRight, ChevronDown, - ChevronLeft, Folder, FolderOpen, - Sun, - Moon, - Monitor, - Globe, Settings, ChevronUp, Users, @@ -42,6 +36,7 @@ interface SidebarProps { onLogout?: () => void; onSearch?: (query: string) => void; quota?: { used: number; total: number } | null; + isPushConnected?: boolean; className?: string; } @@ -200,6 +195,7 @@ export function Sidebar({ onLogout, onSearch, quota, + isPushConnected = false, className, }: SidebarProps) { const [isCollapsed, setIsCollapsed] = useState(false); @@ -209,7 +205,6 @@ export function Sidebar({ const t = useTranslations('sidebar'); const params = useParams(); const router = useRouter(); - const pathname = usePathname(); // Load expanded folders from localStorage on mount useEffect(() => { @@ -427,6 +422,27 @@ export function Sidebar({ Menu + {/* Push Connection Status Indicator */} + + + {/* Tooltip on hover */} + + {isPushConnected ? t("push_connected") : t("push_disconnected")} + + void; } interface ToastProps { @@ -49,8 +50,15 @@ export function ToastItem({ toast, onClose }: ToastProps) {
{ + if (toast.onClick) { + toast.onClick(); + onClose(toast.id); + } + }} >
@@ -60,7 +68,10 @@ export function ToastItem({ toast, onClose }: ToastProps) { )}