feat: Add error boundaries and real-time push notifications

- Implement error boundaries with graceful fallbacks for all major components
  (sidebar, email list, email viewer, composer)
- Add push notifications via EventSource for real-time email updates
- Show connection status indicator in sidebar footer
- Play notification sound and show toast for new emails
- Add global error handler and error reporting utilities
- Configure ESLint with modern flat config
- Various fixes: unused variable warnings, proper cleanup on disconnect
- Update translations for error messages (EN/FR)
This commit is contained in:
Matthieu MALVACHE
2025-12-10 00:43:46 +01:00
parent 1da81fb305
commit 81ec4cb471
27 changed files with 6084 additions and 156 deletions
+7 -7
View File
@@ -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
+53
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{t("page_error_title")}
</h2>
<p className="text-muted-foreground mb-6">
{t("page_error_description")}
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push(`/${params.locale}`)}>
<Home className="w-4 h-4 mr-2" />
{t("go_home")}
</Button>
<Button onClick={reset}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("try_again")}
</Button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export default async function LocaleLayout({
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch (error) {
} catch {
notFound();
}
+2 -2
View File
@@ -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");
}
}
+158 -73
View File
@@ -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 (
<div className="flex h-screen bg-background">
{/* Sidebar */}
<Sidebar
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMailboxSelect={handleMailboxSelect}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
onLogout={handleLogout}
onSearch={handleSearch}
quota={quota}
/>
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMailboxSelect={handleMailboxSelect}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
onLogout={handleLogout}
onSearch={handleSearch}
quota={quota}
isPushConnected={isPushConnected}
/>
</ErrorBoundary>
{/* Email List */}
<div className="w-96 bg-background border-r border-border flex-shrink-0 shadow-sm">
<EmailList
emails={emails}
selectedEmailId={selectedEmail?.id}
isLoading={isLoading}
onEmailSelect={async (email) => {
if (!client || !email) return;
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
selectedEmailId={selectedEmail?.id}
isLoading={isLoading}
onEmailSelect={async (email) => {
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"
/>
</ErrorBoundary>
</div>
{/* Email Viewer */}
<EmailViewer
email={selectedEmail}
isLoading={isLoadingEmail}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
}
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
/>
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
isLoading={isLoadingEmail}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
}
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
/>
</ErrorBoundary>
{/* Email Composer Modal */}
{showComposer && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="w-full max-w-3xl h-[600px] mx-4">
<EmailComposer
mode={composerMode}
replyTo={selectedEmail ? {
from: selectedEmail.from,
to: selectedEmail.to,
cc: selectedEmail.cc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
receivedAt: selectedEmail.receivedAt
} : undefined}
onSend={handleEmailSend}
onClose={() => {
<ErrorBoundary
fallback={ComposerErrorFallback}
onReset={() => {
setShowComposer(false);
setComposerMode('compose');
}}
onDiscardDraft={handleDiscardDraft}
/>
>
<EmailComposer
mode={composerMode}
replyTo={selectedEmail ? {
from: selectedEmail.from,
to: selectedEmail.to,
cc: selectedEmail.cc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
receivedAt: selectedEmail.receivedAt
} : undefined}
onSend={handleEmailSend}
onClose={() => {
setShowComposer(false);
setComposerMode('compose');
}}
onDiscardDraft={handleDiscardDraft}
/>
</ErrorBoundary>
</div>
</div>
)}
+48
View File
@@ -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 <html> and <body> 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 (
<html lang="en">
<body className="bg-gray-50 dark:bg-gray-900">
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md px-4">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertTriangle className="w-10 h-10 text-red-600 dark:text-red-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Something went wrong
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
An unexpected error occurred. Please try again.
</p>
<button
onClick={reset}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4 mr-2" />
Try again
</button>
</div>
</div>
</body>
</html>
);
}
+1
View File
@@ -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 () => {
+1 -1
View File
@@ -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";
-2
View File
@@ -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);
+3 -12
View File
@@ -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
+94
View File
@@ -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
* <ErrorBoundary fallback={SidebarErrorFallback}>
* <Sidebar />
* </ErrorBoundary>
*/
export function ErrorBoundary({
children,
fallback,
onError,
onReset,
}: ErrorBoundaryProps) {
const t = useTranslations("errors");
return (
<ErrorBoundaryCore
fallback={fallback}
onError={onError}
onReset={onReset}
t={t}
>
{children}
</ErrorBoundaryCore>
);
}
+127
View File
@@ -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 (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{t("page_error_title")}
</h2>
<p className="text-muted-foreground mb-6">
{t("page_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("try_again")}
</Button>
</div>
</div>
);
}
/**
* Sidebar error fallback - matches sidebar width (256px).
*/
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-64 h-full border-r border-border bg-secondary flex flex-col items-center justify-center p-4">
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("sidebar_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-3 h-3 mr-1" />
{t("reload")}
</Button>
</div>
);
}
/**
* Email list error fallback - matches email list panel width (384px).
*/
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-full h-full bg-background flex flex-col items-center justify-center p-4">
<Inbox className="w-12 h-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("email_list_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("reload_emails")}
</Button>
</div>
);
}
/**
* Email viewer error fallback - fills remaining space (flex-1).
*/
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-muted/30 p-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<Mail className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("viewer_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6 max-w-md">
{t("viewer_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("try_again")}
</Button>
</div>
);
}
/**
* Email composer modal error fallback.
*/
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
<AlertCircle className="w-10 h-10 text-amber-500 mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("composer_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
{t("retry")}
</Button>
</div>
);
}
/**
* Settings page error fallback.
*/
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8">
<Settings className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{t("settings_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6">
{t("settings_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
{t("reload_settings")}
</Button>
</div>
);
}
+10
View File
@@ -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";
+24 -8
View File
@@ -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({
<span className="flex items-center gap-2">
<Menu className="w-4 h-4" />
Menu
{/* Push Connection Status Indicator */}
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
{/* Tooltip on hover */}
<span className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1",
"bg-popover text-popover-foreground text-xs rounded shadow-lg",
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{isPushConnected ? t("push_connected") : t("push_disconnected")}
</span>
</span>
</span>
<ChevronUp className={cn(
"w-4 h-4 transition-transform duration-200",
-1
View File
@@ -2,7 +2,6 @@
import { useParams, usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
import { locales } from '@/i18n/request';
+14 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -12,6 +12,7 @@ export interface Toast {
title: string;
message?: string;
duration?: number;
onClick?: () => void;
}
interface ToastProps {
@@ -49,8 +50,15 @@ export function ToastItem({ toast, onClose }: ToastProps) {
<div
className={cn(
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
styles[toast.type]
styles[toast.type],
toast.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => {
if (toast.onClick) {
toast.onClick();
onClose(toast.id);
}
}}
>
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="flex-1">
@@ -60,7 +68,10 @@ export function ToastItem({ toast, onClose }: ToastProps) {
)}
</div>
<button
onClick={() => onClose(toast.id)}
onClick={(e) => {
e.stopPropagation();
onClose(toast.id);
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
+60
View File
@@ -0,0 +1,60 @@
import js from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import globals from "globals";
export default [
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.node,
React: "readonly",
JSX: "readonly",
NodeJS: "readonly",
},
},
plugins: {
"@typescript-eslint": tseslint,
"react": reactPlugin,
"react-hooks": reactHooksPlugin,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["warn", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-unused-vars": "off",
},
settings: {
react: {
version: "detect",
},
},
},
{
ignores: [
".next/**",
"node_modules/**",
"*.config.js",
"*.config.mjs",
],
},
];
+43
View File
@@ -0,0 +1,43 @@
import { debug } from "./debug";
interface ErrorReport {
error: Error;
errorInfo?: React.ErrorInfo;
zone: string;
timestamp: Date;
userAgent: string;
url: string;
}
/**
* Report an error to the logging system.
* In debug mode, logs detailed information to the console.
* Future: Can be extended to send to external error tracking services.
*/
export function reportError(
error: Error,
zone: string,
errorInfo?: React.ErrorInfo
): void {
const report: ErrorReport = {
error,
errorInfo,
zone,
timestamp: new Date(),
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "SSR",
url: typeof window !== "undefined" ? window.location.href : "",
};
// Always log errors
debug.error(`[ErrorBoundary:${zone}]`, error.message, {
stack: error.stack,
componentStack: errorInfo?.componentStack,
url: report.url,
timestamp: report.timestamp.toISOString(),
});
// Future: Send to error tracking service (Sentry, etc.)
// if (process.env.NODE_ENV === 'production') {
// sendToErrorService(report);
// }
}
+172 -27
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox } from "./types";
import type { Email, Mailbox, StateChange, AccountStates } from "./types";
export class JMAPClient {
private serverUrl: string;
@@ -13,6 +13,9 @@ export class JMAPClient {
private lastPingTime: number = 0;
private pingInterval: NodeJS.Timeout | null = null;
private accounts: Record<string, any> = {}; // All accounts (primary + shared)
private eventSource: EventSource | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null;
private lastStates: AccountStates = {};
constructor(serverUrl: string, username: string, password: string) {
this.serverUrl = serverUrl.replace(/\/$/, '');
@@ -132,6 +135,7 @@ export class JMAPClient {
disconnect(): void {
this.stopKeepAlive();
this.closePushNotifications();
this.apiUrl = "";
this.accountId = "";
this.session = null;
@@ -167,7 +171,7 @@ export class JMAPClient {
let data;
try {
data = JSON.parse(responseText);
} catch (e) {
} catch {
console.error('Failed to parse response:', responseText);
throw new Error('Invalid JSON response from server');
}
@@ -197,7 +201,7 @@ export class JMAPClient {
}
return null;
} catch (error) {
} catch {
return null;
}
}
@@ -1004,7 +1008,7 @@ export class JMAPClient {
try {
result = JSON.parse(responseText);
console.log('Parsed upload response:', JSON.stringify(result, null, 2));
} catch (e) {
} catch {
console.error('Failed to parse upload response as JSON:', responseText);
throw new Error('Invalid JSON response from upload');
}
@@ -1082,10 +1086,22 @@ export class JMAPClient {
getEventSourceUrl(): string | null {
const session = this.session;
if (!session?.capabilities?.["urn:ietf:params:jmap:core"]?.eventSourceUrl) {
if (!session) {
return null;
}
return session.capabilities["urn:ietf:params:jmap:core"].eventSourceUrl;
// RFC 8620: eventSourceUrl is at session root level
if (session.eventSourceUrl) {
return session.eventSourceUrl;
}
// Some servers may put it in capabilities
if (session.capabilities?.["urn:ietf:params:jmap:core"]?.eventSourceUrl) {
return session.capabilities["urn:ietf:params:jmap:core"].eventSourceUrl;
}
return null;
}
getAccountId(): string {
return this.accountId;
}
supportsEmailSubmission(): boolean {
@@ -1103,35 +1119,164 @@ export class JMAPClient {
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type);
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);
}
// Real-time Updates via Polling (EventSource has auth limitations with Basic Auth)
private pollingInterval: NodeJS.Timeout | null = null;
private pollingStates: { [key: string]: string } = {};
setupPushNotifications(): boolean {
// Use polling instead of EventSource due to Basic Auth limitations
// EventSource can't send Authorization headers, and URL-embedded credentials
// get decoded by browsers, breaking auth for usernames/passwords with special chars
// Initial state fetch
this.fetchCurrentStates();
// Set up polling interval
this.pollingInterval = setInterval(() => {
this.checkForStateChanges();
}, 15000); // Poll every 15 seconds
return true;
}
private async fetchCurrentStates(): Promise<void> {
try {
const response = await fetch(url, {
// Get current states from server using JMAP query
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.authHeader,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
],
}),
});
if (!response.ok) {
throw new Error(`Failed to download attachment: ${response.status}`);
if (response.ok) {
const data = await response.json();
// Extract states from response
for (const [method, result] of data.methodResponses) {
if (method === 'Mailbox/get' && result.state) {
this.pollingStates['Mailbox'] = result.state;
}
if (method === 'Email/get' && result.state) {
this.pollingStates['Email'] = result.state;
}
}
}
// 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;
// Silently fail - polling will retry
}
}
private async checkForStateChanges(): Promise<void> {
try {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.authHeader,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
],
}),
});
if (response.ok) {
const data = await response.json();
const changes: { [key: string]: string } = {};
let hasChanges = false;
for (const [method, result] of data.methodResponses) {
if (method === 'Mailbox/get' && result.state) {
if (this.pollingStates['Mailbox'] && this.pollingStates['Mailbox'] !== result.state) {
changes['Mailbox'] = result.state;
hasChanges = true;
}
this.pollingStates['Mailbox'] = result.state;
}
if (method === 'Email/get' && result.state) {
if (this.pollingStates['Email'] && this.pollingStates['Email'] !== result.state) {
changes['Email'] = result.state;
hasChanges = true;
}
this.pollingStates['Email'] = result.state;
}
}
if (hasChanges && this.stateChangeCallback) {
this.stateChangeCallback({
'@type': 'StateChange',
changed: {
[this.accountId]: changes,
},
});
}
}
} catch (error) {
// Silently fail - polling will retry
}
}
closePushNotifications(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.stateChangeCallback = null;
this.pollingStates = {};
}
onStateChange(callback: (change: StateChange) => void): void {
this.stateChangeCallback = callback;
}
getLastStates(): AccountStates {
return { ...this.lastStates };
}
setLastStates(states: AccountStates): void {
this.lastStates = { ...states };
}
}
+37
View File
@@ -161,4 +161,41 @@ export interface DeliveryStatus {
smtpReply: string;
delivered: "queued" | "yes" | "no" | "unknown";
displayed: "unknown" | "yes";
}
// JMAP Push Notification Types (RFC 8620 Section 7)
export interface StateChange {
'@type': 'StateChange';
changed: {
[accountId: string]: {
Email?: string;
Mailbox?: string;
Thread?: string;
EmailDelivery?: string;
EmailSubmission?: string;
Identity?: string;
};
};
}
export interface PushSubscription {
id: string;
deviceClientId: string;
url: string;
keys: {
p256dh: string;
auth: string;
} | null;
expires: string | null;
types: string[] | null;
}
// For tracking last known states
export interface AccountStates {
[accountId: string]: {
Email?: string;
Mailbox?: string;
Thread?: string;
};
}
-1
View File
@@ -62,7 +62,6 @@ const ROLE_PRIORITY: Record<string, number> = {
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const roleMap = new Map<string, Mailbox>();
const nameMap = new Map<string, Mailbox>();
const result: Mailbox[] = [];
// First pass: collect mailboxes with roles
+23 -1
View File
@@ -20,6 +20,8 @@
"sign_out": "Sign out",
"settings": "Settings",
"loading_mailboxes": "Loading mailboxes...",
"push_connected": "Real-time updates active",
"push_disconnected": "Real-time updates inactive",
"theme": {
"light": "Light mode",
"dark": "Dark mode",
@@ -178,7 +180,10 @@
"source_copied": "Source copied to clipboard",
"error_sending": "Failed to send email",
"error_deleting": "Failed to delete email",
"error_loading": "Failed to load emails"
"error_loading": "Failed to load emails",
"new_email": "New email",
"new_email_from": "From {sender}",
"click_to_view": "Click to view"
},
"date": {
"today": "Today",
@@ -404,5 +409,22 @@
"button": "Import"
}
}
},
"errors": {
"page_error_title": "Something went wrong",
"page_error_description": "We encountered an unexpected error. Please try again or return to the home page.",
"sidebar_error": "Unable to load mailboxes",
"email_list_error": "Unable to load emails",
"viewer_error_title": "Unable to display email",
"viewer_error_description": "There was a problem rendering this email. It may contain unsupported content.",
"composer_error": "Unable to load composer",
"settings_error_title": "Settings unavailable",
"settings_error_description": "Unable to load settings. Your preferences may not be saved.",
"try_again": "Try again",
"reload": "Reload",
"reload_emails": "Reload emails",
"reload_settings": "Reload settings",
"retry": "Retry",
"go_home": "Go to inbox"
}
}
+23 -1
View File
@@ -20,6 +20,8 @@
"sign_out": "Se déconnecter",
"settings": "Paramètres",
"loading_mailboxes": "Chargement des boîtes mail...",
"push_connected": "Mises à jour en temps réel actives",
"push_disconnected": "Mises à jour en temps réel inactives",
"theme": {
"light": "Mode clair",
"dark": "Mode sombre",
@@ -178,7 +180,10 @@
"source_copied": "Source copiée 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"
"error_loading": "Échec du chargement des emails",
"new_email": "Nouvel email",
"new_email_from": "De {sender}",
"click_to_view": "Cliquer pour voir"
},
"date": {
"today": "Aujourd'hui",
@@ -404,5 +409,22 @@
"button": "Importer"
}
}
},
"errors": {
"page_error_title": "Une erreur s'est produite",
"page_error_description": "Nous avons rencontré une erreur inattendue. Veuillez réessayer ou retourner à la page d'accueil.",
"sidebar_error": "Impossible de charger les boîtes mail",
"email_list_error": "Impossible de charger les emails",
"viewer_error_title": "Impossible d'afficher l'email",
"viewer_error_description": "Un problème est survenu lors de l'affichage de cet email. Il peut contenir du contenu non pris en charge.",
"composer_error": "Impossible de charger le compositeur",
"settings_error_title": "Paramètres indisponibles",
"settings_error_description": "Impossible de charger les paramètres. Vos préférences peuvent ne pas être sauvegardées.",
"try_again": "Réessayer",
"reload": "Recharger",
"reload_emails": "Recharger les emails",
"reload_settings": "Recharger les paramètres",
"retry": "Réessayer",
"go_home": "Aller à la boîte de réception"
}
}
+5073 -12
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -5,7 +5,9 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start"
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix"
},
"dependencies": {
"@types/dompurify": "^3.0.5",
@@ -27,6 +29,12 @@
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.8",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"tailwindcss": "^4.1.17",
"typescript": "^5"
}
+100 -2
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Email, Mailbox } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
@@ -18,6 +18,9 @@ interface EmailStore {
selectedEmailIds: Set<string>; // Track selected emails for batch operations
hasMoreEmails: boolean; // Track if more emails are available to load
totalEmails: number; // Total number of emails in the current mailbox/query
isPushConnected: boolean; // Track if push notifications are connected
lastPushUpdate: number | null; // Timestamp of last push update
newEmailNotification: Email | null; // New email notification for toast
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
@@ -50,6 +53,13 @@ interface EmailStore {
batchDelete: (client: JMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>;
handleNewEmailNotification: (email: Email) => void;
clearNewEmailNotification: () => void;
// Mock data for demo
loadMockData: () => void;
}
@@ -69,6 +79,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedEmailIds: new Set(),
hasMoreEmails: false,
totalEmails: 0,
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
@@ -227,7 +240,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const quota = await client.getQuota();
set({ quota });
} catch (error) {
} catch {
// Don't set error state as quota is optional
}
},
@@ -697,6 +710,91 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
// Push notification handlers
setPushConnected: (connected) => {
set({ isPushConnected: connected });
},
handleStateChange: async (change, client) => {
try {
// Update last push update timestamp
set({ lastPushUpdate: Date.now() });
// Get the current account ID from the client (assuming primary account)
const accountId = client.getAccountId();
// Check if there are changes for this account
const accountChanges = change.changed[accountId];
if (!accountChanges) return;
// Handle Email state changes - refresh current mailbox
if (accountChanges.Email) {
await get().refreshCurrentMailbox(client);
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
await get().fetchMailboxes(client);
}
// Could also handle Thread, EmailSubmission, Identity changes in the future
} catch (error) {
console.error('Failed to handle state change:', error);
set({
error: error instanceof Error ? error.message : "Failed to handle push notification"
});
}
},
refreshCurrentMailbox: async (client) => {
const { selectedMailbox } = get();
// Only refresh if a mailbox is currently selected
if (!selectedMailbox) return;
try {
// Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
// Check if there are new emails by comparing the first email ID
const currentFirstEmailId = get().emails[0]?.id;
const newFirstEmailId = result.emails[0]?.id;
// If the first email changed, we have a new email - trigger notification
if (currentFirstEmailId !== newFirstEmailId && result.emails[0]) {
get().handleNewEmailNotification(result.emails[0]);
}
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total
});
} catch (error) {
console.error('Failed to refresh current mailbox:', error);
// Don't set error state for background refreshes to avoid disrupting the UI
}
},
handleNewEmailNotification: (email) => {
// Set the new email notification state
// This can be consumed by a toast component
set({ newEmailNotification: email });
},
clearNewEmailNotification: () => {
set({ newEmailNotification: null });
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+1 -1
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Toast, ToastType } from "@/components/ui/toast";
import { Toast } from "@/components/ui/toast";
interface ToastStore {
toasts: Toast[];