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) {
)}