mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-26 16:01:18 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export default async function LocaleLayout({
|
||||
let messages;
|
||||
try {
|
||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+5073
-12
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -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
@@ -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,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { Toast, ToastType } from "@/components/ui/toast";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
|
||||
Reference in New Issue
Block a user