Initial release: JMAP Webmail Client

A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
Designed for Stalwart Mail Server.

Features:
- Full email operations (compose, reply, forward, threading)
- Real-time push notifications
- Dark/light theme support
- Mobile responsive design
- Keyboard shortcuts
- Drag-and-drop organization
- i18n (English/French)
- Security-first (external content blocked, HTML sanitization)
This commit is contained in:
Matthieu MALVACHE
2025-12-10 17:54:22 +01:00
committed by Matthieu MALVACHE
commit cf21a84263
79 changed files with 21821 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# JMAP Webmail Configuration
# Copy this file to .env.local and fill in your values
# App name displayed in the UI
NEXT_PUBLIC_APP_NAME=JMAP Webmail
# JMAP server URL (required)
# This is the URL of your JMAP-compatible mail server
NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# claude code
.claude/
+1
View File
@@ -0,0 +1 @@
npx tsc --noEmit && npx eslint . --ext .ts,.tsx
+190
View File
@@ -0,0 +1,190 @@
# Contributing to JMAP Webmail
Thank you for your interest in contributing to JMAP Webmail! This document provides guidelines and information for contributors.
## Getting Started
### Development Setup
1. **Fork and clone** the repository:
```bash
git clone https://github.com/root-fr/jmap-webmail.git
cd jmap-webmail
```
2. **Install dependencies**:
```bash
npm install
```
3. **Set up environment**:
```bash
cp .env.example .env.local
# Edit .env.local with your JMAP server URL
```
4. **Start development server**:
```bash
npm run dev
```
### Code Quality
Before submitting a pull request, ensure your code passes all checks:
```bash
# Type checking
npm run typecheck
# Linting
npm run lint
# Fix lint issues automatically
npm run lint:fix
```
These checks run automatically on commit via Husky pre-commit hooks.
## Code Style Guidelines
### TypeScript
- Use TypeScript for all new code
- Define proper types and interfaces
- Avoid `any` types when possible
- Use meaningful variable and function names
### React Components
- Use functional components with hooks
- Keep components focused and single-purpose
- Extract reusable logic into custom hooks
- Place components in appropriate directories under `/components`
### Styling
- Use Tailwind CSS utility classes
- Follow the existing design system
- Support both dark and light themes
- Use CSS variables for theme colors
## Internationalization (i18n)
This project uses **next-intl** for internationalization. Please follow these guidelines:
### Key Rules
1. **Never hardcode user-facing text** - Always use translations:
```tsx
const t = useTranslations('namespace');
return <div>{t('key')}</div>;
```
2. **Translation file locations**:
- English: `/locales/en/common.json`
- French: `/locales/fr/common.json`
3. **Namespace organization**:
- `login.*` - Login page strings
- `sidebar.*` - Sidebar navigation
- `email_list.*` - Email list component
- `email_viewer.*` - Email viewer component
- `email_composer.*` - Email composer
- `common.*` - Shared strings
- `notifications.*` - Toast/alert messages
- `settings.*` - Settings page
4. **Adding new strings**:
- Add to **both** English and French translation files
- Use descriptive, hierarchical keys
- Keep translations consistent in tone
5. **Locale-aware navigation**:
```tsx
router.push(`/${params.locale}/settings`);
```
## Pull Request Process
### Before Submitting
1. **Create a feature branch**:
```bash
git checkout -b feature/your-feature-name
```
2. **Make your changes** following the code style guidelines
3. **Test your changes** thoroughly
4. **Update translations** if you added user-facing text
5. **Run all checks**:
```bash
npm run typecheck && npm run lint
```
### Submitting
1. **Push your branch** to your fork
2. **Open a Pull Request** with:
- Clear title describing the change
- Description of what was changed and why
- Screenshots for UI changes
- Reference to any related issues
### Commit Message Convention
Follow the conventional commits format:
- `feat:` - New features
- `fix:` - Bug fixes
- `docs:` - Documentation changes
- `style:` - Code style changes (formatting, etc.)
- `refactor:` - Code refactoring
- `test:` - Adding or updating tests
- `chore:` - Maintenance tasks
Examples:
```
feat: add email threading support
fix: resolve attachment download issue
docs: update README with keyboard shortcuts
```
## Project Structure
```
jmap-webmail/
├── app/ # Next.js App Router pages
│ └── [locale]/ # Locale-aware routing
├── components/ # React components
│ ├── email/ # Email-related components
│ ├── layout/ # Layout components
│ ├── settings/ # Settings components
│ └── ui/ # Reusable UI components
├── contexts/ # React contexts
├── hooks/ # Custom React hooks
├── lib/ # Utilities and libraries
│ └── jmap/ # JMAP client implementation
├── locales/ # Translation files
│ ├── en/ # English translations
│ └── fr/ # French translations
└── stores/ # Zustand state stores
```
## Security
- **Never commit sensitive data** (API keys, passwords, etc.)
- **Sanitize user input** and email content
- **Block external content** by default for privacy
- Report security vulnerabilities privately
## Questions?
If you have questions about contributing, feel free to:
- Open an issue for discussion
- Check existing issues and pull requests
Thank you for helping improve JMAP Webmail!
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Matthieu MALVACHE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+163
View File
@@ -0,0 +1,163 @@
# JMAP Webmail
A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
## Built for Stalwart
This webmail client is designed to work seamlessly with [**Stalwart Mail Server**](https://stalw.art/) - a modern, secure, and blazingly fast mail server written in Rust.
**Why Stalwart?**
- **Modern Architecture**: Built from the ground up with Rust for performance and safety
- **JMAP-Native**: First-class support for the JMAP protocol (not just IMAP/SMTP bolted on)
- **Privacy-Focused**: Self-hosted, no third-party dependencies, full control over your data
- **Feature-Rich**: Supports JMAP, IMAP, SMTP, ManageSieve, and more
[Stalwart GitHub](https://github.com/stalwartlabs/mail-server) | [Documentation](https://stalw.art/docs/)
## Features
### Core Email Operations
- Read, compose, reply, reply-all, and forward emails
- Full HTML email rendering with security sanitization
- Attachment upload and download
- Draft auto-save with discard confirmation
- Email threading with Gmail-style inline expansion
- Mark as read/unread, star/unstar
- Archive and delete with configurable behavior
- Color tags/labels for email organization
- Full-text search
### User Interface
- Clean, minimalist three-pane layout
- Dark and light theme support
- Responsive design for mobile and desktop
- Keyboard shortcuts for power users
- Drag-and-drop email organization
- Right-click context menus
- Smooth animations and transitions
- Infinite scroll pagination
### Real-time Updates
- Push notifications via JMAP EventSource
- Real-time unread counts
- Live email arrival notifications
- Connection status indicator
### Security & Privacy
- External content blocked by default
- HTML sanitization with DOMPurify
- SPF/DKIM/DMARC status indicators
- No password storage (session-based auth)
- Shared folder support with proper permissions
### Internationalization
- English and French language support
- Automatic browser language detection
- Persistent language preference
## Tech Stack
- **Framework**: [Next.js 16](https://nextjs.org/) with App Router
- **Language**: TypeScript
- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/)
- **State Management**: [Zustand](https://zustand-demo.pmnd.rs/)
- **JMAP Client**: [jmap-jam](https://www.npmjs.com/package/jmap-jam)
- **i18n**: [next-intl](https://next-intl-docs.vercel.app/)
- **Icons**: [Lucide React](https://lucide.dev/)
## Getting Started
### Prerequisites
- Node.js 18+
- A JMAP-compatible mail server (we recommend [Stalwart](https://stalw.art/))
### Installation
```bash
# Clone the repository
git clone https://github.com/root-fr/jmap-webmail.git
cd jmap-webmail
# Install dependencies
npm install
# Copy environment configuration
cp .env.example .env.local
```
### Configuration
Edit `.env.local` with your settings:
```env
# App name displayed in the UI
NEXT_PUBLIC_APP_NAME=My Webmail
# Your JMAP server URL
NEXT_PUBLIC_JMAP_SERVER_URL=https://mail.example.com
```
### Development
```bash
# Start development server
npm run dev
# Type checking
npm run typecheck
# Linting
npm run lint
```
### Production Build
```bash
# Build for production
npm run build
# Start production server
npm start
```
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `j` / `k` | Navigate between emails |
| `Enter` / `o` | Open selected email |
| `Esc` | Close viewer / deselect |
| `c` | Compose new email |
| `r` | Reply |
| `R` / `a` | Reply all |
| `f` | Forward |
| `s` | Toggle star |
| `e` | Archive |
| `#` / `Delete` | Delete |
| `u` | Mark as unread |
| `/` | Focus search |
| `x` | Expand/collapse thread |
| `?` | Show shortcuts help |
## Screenshots
*Coming soon*
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Roadmap
See [ROADMAP.md](ROADMAP.md) for planned features and development status.
## Acknowledgments
- [Stalwart Labs](https://stalw.art/) for creating an excellent JMAP mail server
- The [JMAP](https://jmap.io/) working group for the protocol specification
- All contributors and users of this project
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+133
View File
@@ -0,0 +1,133 @@
# JMAP Webmail - Roadmap
This document tracks the development status and planned features for JMAP Webmail.
## Completed Features
### Core Infrastructure
- [x] Next.js 16 with TypeScript and App Router
- [x] Tailwind CSS v4 with Oxide engine
- [x] Zustand state management
- [x] JMAP client implementation (jmap-jam)
### Authentication
- [x] Login with JMAP server authentication
- [x] Session management (no password storage for security)
- [x] Username autocomplete with history
- [x] Logout functionality
- [x] Authentication error handling
### JMAP Server Connection
- [x] Session establishment and keep-alive
- [x] Connection error handling and retries
- [x] Storage quota display
- [x] Server capability detection
- [x] Shared folders support (multi-account access)
### Email Operations
- [x] Email fetching and display
- [x] Full HTML email rendering
- [x] Compose, reply, reply-all, forward
- [x] Draft auto-save with discard confirmation
- [x] Mark as read/unread
- [x] Star/unstar emails
- [x] Delete and archive
- [x] Color tags/labels
- [x] Full-text search
- [x] Attachment upload and download
- [x] Batch operations (multi-select)
- [x] Quick reply form
- [x] Email threading (Gmail-style inline expansion)
### Real-time Updates
- [x] EventSource for JMAP push notifications
- [x] State synchronization
- [x] Email arrival notifications
- [x] Real-time unread counts
- [x] Mailbox change handling
### User Interface
- [x] Three-pane layout (sidebar, list, viewer)
- [x] Minimalist design system
- [x] Dark and light theme support
- [x] Custom scrollbars
- [x] Mobile responsive design
- [x] Keyboard shortcuts
- [x] Drag-and-drop email organization
- [x] Right-click context menus
- [x] Hierarchical mailbox display
- [x] Email list with avatars and visual hierarchy
- [x] Expandable email headers
- [x] External content warning banner
- [x] SPF/DKIM/DMARC status indicators
- [x] Loading states and skeletons
- [x] Smooth transitions and animations
- [x] Infinite scroll pagination
- [x] Error boundaries
- [x] Settings page with preferences
### Internationalization
- [x] English language support
- [x] French language support
- [x] Automatic browser language detection
- [x] Language preference persistence
### Security
- [x] External content blocked by default
- [x] HTML sanitization with DOMPurify
- [x] User control for loading external content
## Planned Features
### Address Book & Contacts
- [ ] Contact store with CRUD operations
- [ ] Contacts list view with search/filter
- [ ] Contact details view/edit form
- [ ] Contact groups management
- [ ] vCard import/export
- [ ] JMAP contacts sync (if server supports)
- [ ] Email autocomplete from contacts
- [ ] Contacts integration in composer
### Advanced Features
- [ ] Email filters and rules
- [ ] Calendar integration (JMAP Calendars)
- [ ] Email templates
- [ ] Signature management
- [ ] Vacation responder settings
- [ ] Email aliases support
- [ ] Advanced search with filters
- [ ] Email encryption (PGP/GPG)
### Performance Optimizations
- [ ] Virtual scrolling for large lists
- [ ] Email content caching
- [ ] Bundle size optimization
- [ ] Service worker for offline support
- [ ] Lazy loading for attachments
### Testing
- [ ] Unit tests for utilities
- [ ] Component tests
- [ ] E2E tests with Playwright
- [ ] Accessibility testing
- [ ] Performance testing
### Deployment
- [ ] Health check endpoint
- [ ] Production build optimizations
- [ ] Monitoring and logging
### Security Enhancements
- [ ] CSP headers configuration
- [ ] Additional XSS protection layers
- [ ] Rate limiting
- [ ] CORS configuration
## Known Issues
- [ ] Next.js workspace root warning (cosmetic)
## Contributing
Want to help implement a feature? Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide!
+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>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { locales } from "@/i18n/request";
import "../globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "JMAP Webmail",
description: "Minimalist webmail client using JMAP protocol",
};
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Validate that the incoming `locale` parameter is valid
if (!(locales as readonly string[]).includes(locale)) notFound();
// Load messages for the current locale
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
notFound();
}
return (
<html lang={locale} suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('theme-storage');
const theme = stored ? JSON.parse(stored).state.theme : 'system';
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const resolved = theme === 'system' ? systemTheme : theme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
} catch (e) {
document.documentElement.classList.add('light');
}
})();
`,
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider locale={locale} messages={messages}>
<ThemeProvider>
{children}
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);
}
+311
View File
@@ -0,0 +1,311 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { Mail, AlertCircle, Loader2, X } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const params = useParams();
const t = useTranslations("login");
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const serverUrl = process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
// All hooks must be called unconditionally at the top
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false);
// Set page title
useEffect(() => {
if (serverUrl) {
document.title = appName;
}
}, [appName, serverUrl]);
// Load saved usernames from localStorage on mount
useEffect(() => {
if (!serverUrl) return;
const saved = localStorage.getItem("webmail_usernames");
if (saved) {
try {
const usernames = JSON.parse(saved);
setSavedUsernames(usernames);
} catch {
console.error("Failed to parse saved usernames");
}
}
}, [serverUrl]);
useEffect(() => {
if (isAuthenticated) {
router.push(`/${params.locale}`);
}
}, [isAuthenticated, router, params.locale]);
useEffect(() => {
clearError();
}, [formData, clearError]);
// Filter suggestions based on input
useEffect(() => {
if (!serverUrl) return;
// Skip showing suggestions if we just selected one
if (justSelectedSuggestion.current) {
justSelectedSuggestion.current = false;
return;
}
if (formData.username && savedUsernames.length > 0) {
const filtered = savedUsernames.filter(username =>
username.toLowerCase().includes(formData.username.toLowerCase())
);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else if (formData.username === "" && savedUsernames.length > 0) {
setFilteredSuggestions(savedUsernames);
setShowSuggestions(false); // Don't show on empty input
} else {
setShowSuggestions(false);
}
setSelectedSuggestionIndex(-1);
}, [formData.username, savedUsernames, serverUrl]);
// Close suggestions when clicking outside
useEffect(() => {
if (!serverUrl) return;
const handleClickOutside = (event: MouseEvent) => {
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) &&
inputRef.current && !inputRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [serverUrl]);
// Show error if JMAP server URL is not configured
if (!serverUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">Configuration Error</h1>
<p className="text-muted-foreground text-sm">
NEXT_PUBLIC_JMAP_SERVER_URL environment variable is not set.
</p>
</div>
</div>
);
}
// Save username on successful login
const saveUsername = (username: string) => {
const saved = localStorage.getItem("webmail_usernames");
let usernames: string[] = [];
if (saved) {
try {
usernames = JSON.parse(saved);
} catch {
console.error("Failed to parse saved usernames");
}
}
// Add username if not already present, keep max 5 recent usernames
if (!usernames.includes(username)) {
usernames = [username, ...usernames].slice(0, 5);
localStorage.setItem("webmail_usernames", JSON.stringify(usernames));
setSavedUsernames(usernames);
}
};
// Remove a username from saved list
const removeUsername = (username: string, e: React.MouseEvent) => {
e.stopPropagation();
const updated = savedUsernames.filter(u => u !== username);
localStorage.setItem("webmail_usernames", JSON.stringify(updated));
setSavedUsernames(updated);
setFilteredSuggestions(updated.filter(u =>
u.toLowerCase().includes(formData.username.toLowerCase())
));
};
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, username: e.target.value });
};
const handleUsernameFocus = () => {
if (savedUsernames.length > 0 && formData.username === "") {
setFilteredSuggestions(savedUsernames);
setShowSuggestions(true);
} else if (filteredSuggestions.length > 0) {
setShowSuggestions(true);
}
};
const selectSuggestion = (username: string) => {
justSelectedSuggestion.current = true;
setFormData({ ...formData, username });
setShowSuggestions(false);
// Focus password field
document.getElementById("password")?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showSuggestions || filteredSuggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedSuggestionIndex(prev =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedSuggestionIndex(prev => prev > 0 ? prev - 1 : -1);
} else if (e.key === "Enter" && selectedSuggestionIndex >= 0) {
e.preventDefault();
selectSuggestion(filteredSuggestions[selectedSuggestionIndex]);
} else if (e.key === "Escape") {
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const success = await login(
serverUrl,
formData.username,
formData.password
);
if (success) {
saveUsername(formData.username);
router.push(`/${params.locale}`);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4">
{/* Logo */}
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-6 shadow-lg shadow-primary/5">
<Mail className="w-10 h-10 text-primary" />
</div>
<h1 className="text-3xl font-light text-foreground tracking-tight">
{appName}
</h1>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">
{t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-4">
<div className="relative">
<Input
ref={inputRef}
id="username"
type="text"
value={formData.username}
onChange={handleUsernameChange}
onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("username_placeholder")}
required
autoComplete="off"
data-form-type="other"
data-lpignore="true"
autoFocus
/>
{/* Custom autocomplete dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute top-full mt-1 w-full bg-secondary border border-border rounded-md shadow-lg z-50 overflow-hidden"
>
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={`px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors ${
index === selectedSuggestionIndex ? "bg-muted" : ""
}`}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
<button
type="button"
onClick={(e) => removeUsername(username, e)}
className="p-1 hover:bg-background rounded transition-colors"
title="Remove from history"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div>
)}
</div>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
/>
</div>
<Button
type="submit"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
t("sign_in")
)}
</Button>
</form>
</div>
</div>
);
}
+837
View File
@@ -0,0 +1,837 @@
"use client";
import { useEffect, useState, useRef, useMemo } from "react";
import { useRouter, useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list";
import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email } from "@/lib/jmap/types";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { debug } from "@/lib/debug";
import { cn } from "@/lib/utils";
import {
ErrorBoundary,
SidebarErrorFallback,
EmailListErrorFallback,
EmailViewerErrorFallback,
ComposerErrorFallback,
} from "@/components/error";
import { DragDropProvider } from "@/contexts/drag-drop-context";
export default function Home() {
const router = useRouter();
const params = useParams();
const t = useTranslations();
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [initialCheckDone, setInitialCheckDone] = useState(false);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// Mobile conversation view state
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
// Mobile responsive hooks
const { isMobile } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView } = useUIStore();
const {
emails,
mailboxes,
selectedEmail,
selectedMailbox,
quota,
isPushConnected,
newEmailNotification,
selectEmail,
selectMailbox,
selectAllEmails,
clearSelection,
fetchMailboxes,
fetchEmails,
fetchQuota,
sendEmail,
deleteEmail,
markAsRead,
toggleStar,
moveToMailbox,
searchEmails,
isLoading,
isLoadingEmail,
setLoadingEmail,
setPushConnected,
handleStateChange,
clearNewEmailNotification,
} = 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);
}
};
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
if (emails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
if (nextIndex >= 0 && nextIndex < emails.length) {
handleEmailSelect(emails[nextIndex]);
}
},
onPreviousEmail: () => {
if (emails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
if (prevIndex >= 0 && prevIndex < emails.length) {
handleEmailSelect(emails[prevIndex]);
}
},
onOpenEmail: () => {
// Email is already opened when selected
},
onCloseEmail: () => {
selectEmail(null);
if (isMobile) {
setActiveView("list");
}
},
onReply: () => {
if (selectedEmail) handleReply();
},
onReplyAll: () => {
if (selectedEmail) handleReplyAll();
},
onForward: () => {
if (selectedEmail) handleForward();
},
onToggleStar: () => {
if (selectedEmail) handleToggleStar();
},
onArchive: () => {
if (selectedEmail) handleArchive();
},
onDelete: () => {
if (selectedEmail) handleDelete();
},
onMarkAsUnread: async () => {
if (selectedEmail && client) {
await markAsRead(client, selectedEmail.id, false);
}
},
onMarkAsRead: async () => {
if (selectedEmail && client) {
await markAsRead(client, selectedEmail.id, true);
}
},
onCompose: () => {
setComposerMode('compose');
setShowComposer(true);
},
onFocusSearch: () => {
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
searchInput.select();
}
},
onShowHelp: () => {
setShowShortcutsModal(true);
},
onRefresh: async () => {
if (client && selectedMailbox) {
await fetchEmails(client, selectedMailbox);
}
},
onSelectAll: () => {
selectAllEmails();
},
onDeselectAll: () => {
clearSelection();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile]);
// Initialize keyboard shortcuts
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
emails,
selectedEmailId: selectedEmail?.id,
handlers: keyboardHandlers,
});
// Update page title based on context
useEffect(() => {
let title = "Webmail";
if (showComposer) {
// Composing email
const modeText = {
compose: t('email_composer.new_message'),
reply: t('email_composer.reply'),
replyAll: t('email_composer.reply_all'),
forward: t('email_composer.forward'),
}[composerMode] || t('email_composer.new_message');
title = `${modeText} - Webmail`;
} else if (selectedEmail) {
// Reading email
const subject = selectedEmail.subject || t('email_viewer.no_subject');
title = `${subject} - Webmail`;
} else if (selectedMailbox && mailboxes.length > 0) {
// Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
if (mailbox) {
const mailboxName = mailbox.name;
const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - Webmail`
: `${mailboxName} - Webmail`;
}
}
document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
// Check auth on mount
useEffect(() => {
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Redirect to login if not authenticated
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
router.push(`/${params.locale}/login`);
}
}, [initialCheckDone, isAuthenticated, authLoading, router, params.locale]);
// Load mailboxes and emails when authenticated (only if not already loaded)
useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) {
const loadData = async () => {
try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([
fetchMailboxes(client),
fetchQuota(client)
]);
// Get the selected mailbox (should be inbox by default)
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
// Fetch emails for the selected mailbox
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
} 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();
}
// 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(() => {
// Clear any existing timeout when email changes
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Clearing previous timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
// Only set timeout if there's a selected email, it's unread, and we have a client
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
return;
}
// Get current setting value
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
if (markAsReadDelay === -1) {
// Never mark as read automatically
debug.log('[Mark as Read] Never mode - email will stay unread');
} else if (markAsReadDelay === 0) {
// Mark as read instantly
debug.log('[Mark as Read] Instant mode - marking as read now');
markAsRead(client, selectedEmail.id, true);
} else {
// Mark as read after delay
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
markAsReadTimeoutRef.current = setTimeout(() => {
debug.log('[Mark as Read] Timeout fired - marking as read now');
markAsRead(client, selectedEmail.id, true);
markAsReadTimeoutRef.current = null;
}, markAsReadDelay);
}
// Cleanup on unmount or when dependencies change
return () => {
if (markAsReadTimeoutRef.current) {
debug.log('[Mark as Read] Cleanup - clearing timeout');
clearTimeout(markAsReadTimeoutRef.current);
markAsReadTimeoutRef.current = null;
}
};
// 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[];
bcc: string[];
subject: string;
body: string;
draftId?: string;
}) => {
if (!client) return;
try {
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId);
setShowComposer(false);
} catch (error) {
console.error("Failed to send email:", error);
}
};
const handleDiscardDraft = async (draftId: string) => {
if (!client) return;
try {
await client.deleteEmail(draftId);
} catch (error) {
console.error("Failed to discard draft:", error);
}
};
const handleReply = () => {
setComposerMode('reply');
setShowComposer(true);
};
const handleReplyAll = () => {
setComposerMode('replyAll');
setShowComposer(true);
};
const handleForward = () => {
setComposerMode('forward');
setShowComposer(true);
};
const handleDelete = async () => {
if (!client || !selectedEmail) return;
try {
await deleteEmail(client, selectedEmail.id);
selectEmail(null);
} catch (error) {
console.error("Failed to delete email:", error);
}
};
const handleArchive = async () => {
if (!client || !selectedEmail) return;
// Find archive mailbox
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (archiveMailbox) {
try {
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
selectEmail(null);
} catch (error) {
console.error("Failed to archive email:", error);
}
}
};
const handleToggleStar = async () => {
if (!client || !selectedEmail) return;
try {
await toggleStar(client, selectedEmail.id);
} catch (error) {
console.error("Failed to toggle star:", error);
}
};
const handleSetColorTag = async (emailId: string, color: string | null) => {
if (!client) return;
try {
// Remove any existing color tags
const email = emails.find(e => e.id === emailId);
if (!email) return;
const keywords = { ...email.keywords };
// Remove old color tags - set to false for JMAP to remove them
Object.keys(keywords).forEach(key => {
if (key.startsWith("$color:")) {
keywords[key] = false;
}
});
// Add new color tag if specified
if (color) {
keywords[`$color:${color}`] = true;
}
// Update email keywords via JMAP
await client.updateEmailKeywords(emailId, keywords);
// Update local state
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
// Refresh emails list to show color in list
await fetchEmails(client, selectedMailbox);
} catch (error) {
console.error("Failed to set color tag:", error);
}
};
const handleMailboxSelect = async (mailboxId: string) => {
selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes
// On mobile, close sidebar and go to list view
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (client) {
await fetchEmails(client, mailboxId);
}
};
const handleLogout = () => {
logout();
router.push(`/${params.locale}/login`);
};
const handleSearch = async (query: string) => {
if (!client) return;
await searchEmails(client, query);
};
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
if (!client) return;
try {
await client.downloadBlob(blobId, name, type);
} catch (error) {
console.error("Failed to download attachment:", error);
}
};
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
const sender = selectedEmail.from?.[0];
if (!sender?.email) {
throw new Error("No sender email found");
}
// Send reply with just the body text
await sendEmail(
client,
[sender.email],
`Re: ${selectedEmail.subject || "(no subject)"}`,
body
);
// Refresh emails to show the sent reply
await fetchEmails(client, selectedMailbox);
};
// Show loading state while checking auth
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
// Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => {
if (!client || !email) return;
// Set loading state immediately (keep current email visible)
setLoadingEmail(true);
// On mobile, switch to viewer
if (isMobile) {
setActiveView("viewer");
}
// 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
}
} catch (error) {
console.error('Failed to fetch email content:', error);
} finally {
setLoadingEmail(false);
}
};
// Handle back navigation from viewer on mobile
const handleMobileBack = () => {
// If in conversation view, clear it
if (conversationThread) {
setConversationThread(null);
setConversationEmails([]);
}
selectEmail(null);
setActiveView("list");
};
// Handle opening conversation view on mobile
const handleOpenConversation = async (thread: ThreadGroup) => {
if (!client) return;
setConversationThread(thread);
setIsLoadingConversation(true);
setActiveView("viewer");
try {
// Fetch complete thread emails
const emails = await client.getThreadEmails(thread.threadId);
setConversationEmails(emails);
} catch (error) {
console.error('Failed to fetch thread emails:', error);
// Fall back to thread.emails
setConversationEmails(thread.emails);
} finally {
setIsLoadingConversation(false);
}
};
// Handle reply from conversation view
const handleConversationReply = (email: Email) => {
selectEmail(email);
setComposerMode('reply');
setShowComposer(true);
};
const handleConversationReplyAll = (email: Email) => {
selectEmail(email);
setComposerMode('replyAll');
setShowComposer(true);
};
const handleConversationForward = (email: Email) => {
selectEmail(email);
setComposerMode('forward');
setShowComposer(true);
};
return (
<DragDropProvider>
<div className="flex h-screen bg-background overflow-hidden">
{/* Mobile Sidebar Overlay Backdrop */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar - overlay on mobile, fixed on desktop */}
<div
className={cn(
"flex-shrink-0 h-full z-50",
// Mobile: fixed overlay
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:w-72",
"max-md:transform max-md:transition-transform max-md:duration-300 max-md:ease-in-out",
isMobile && !sidebarOpen && "max-md:-translate-x-full",
// Desktop: normal flow
"md:relative md:translate-x-0"
)}
>
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMailboxSelect={handleMailboxSelect}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setSidebarOpen(false);
}}
onLogout={handleLogout}
onSearch={handleSearch}
quota={quota}
isPushConnected={isPushConnected}
/>
</ErrorBoundary>
</div>
{/* Main Content Area */}
<div className="flex flex-1 min-w-0 h-full">
{/* Email List - full width on mobile, fixed width on desktop */}
<div
className={cn(
"flex flex-col h-full bg-background border-r border-border",
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Desktop: fixed width
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm"
)}
>
{/* Mobile Header for List View */}
<MobileHeader
title={currentMailboxName}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
/>
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
selectedEmailId={selectedEmail?.id}
isLoading={isLoading}
onEmailSelect={handleEmailSelect}
onOpenConversation={handleOpenConversation}
// Context menu handlers
onReply={(email) => {
selectEmail(email);
handleReply();
}}
onReplyAll={(email) => {
selectEmail(email);
handleReplyAll();
}}
onForward={(email) => {
selectEmail(email);
handleForward();
}}
onMarkAsRead={async (email, read) => {
if (client) {
await markAsRead(client, email.id, read);
}
}}
onToggleStar={async (email) => {
if (client) {
await toggleStar(client, email.id);
}
}}
onDelete={async (email) => {
selectEmail(email);
await handleDelete();
}}
onArchive={async (email) => {
selectEmail(email);
await handleArchive();
}}
onSetColorTag={(emailId, color) => {
handleSetColorTag(emailId, color);
}}
onMoveToMailbox={async (emailId, mailboxId) => {
if (client) {
await moveToMailbox(client, emailId, mailboxId);
}
}}
className="flex-1"
/>
</ErrorBoundary>
</div>
{/* Email Viewer - full screen on mobile, flex on desktop */}
<div
className={cn(
"flex flex-col h-full bg-background",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Desktop: flex grow
"md:flex-1 md:relative"
)}
>
{/* Mobile Conversation View - shown when thread is selected on mobile */}
{isMobile && conversationThread ? (
<ThreadConversationView
thread={conversationThread}
emails={conversationEmails}
isLoading={isLoadingConversation}
onBack={handleMobileBack}
onReply={handleConversationReply}
onReplyAll={handleConversationReplyAll}
onForward={handleConversationForward}
onDownloadAttachment={handleDownloadAttachment}
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
}
}}
/>
) : (
<>
{/* Mobile Header for Viewer */}
{isMobile && activeView === "viewer" && (
<MobileViewerHeader
subject={selectedEmail?.subject}
onBack={handleMobileBack}
/>
)}
<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]}
className={isMobile ? "flex-1" : undefined}
/>
</ErrorBoundary>
</>
)}
</div>
</div>
{/* Email Composer Modal */}
{showComposer && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 md:p-0">
<div className={cn(
"w-full h-full md:h-auto md:max-w-3xl md:max-h-[600px]",
"max-md:flex max-md:flex-col"
)}>
<ErrorBoundary
fallback={ComposerErrorFallback}
onReset={() => {
setShowComposer(false);
setComposerMode('compose');
}}
>
<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>
)}
{/* Keyboard Shortcuts Modal */}
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</div>
</DragDropProvider>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'advanced';
export default function SettingsPage() {
const router = useRouter();
const params = useParams();
const t = useTranslations('settings');
const [activeTab, setActiveTab] = useState<Tab>('appearance');
const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') },
{ id: 'advanced', label: t('tabs.advanced') },
];
return (
<div className="flex h-screen bg-background">
{/* Settings Sidebar */}
<div className="w-64 border-r border-border bg-secondary flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/${params.locale}`)}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('back_to_mail')}
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded text-sm transition-colors',
activeTab === tab.id
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted text-foreground'
)}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<SettingsIcon className="w-8 h-8 text-foreground" />
<h1 className="text-3xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6">
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</div>
</div>
</div>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+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>
);
}
+380
View File
@@ -0,0 +1,380 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--color-border: #e2e8f0;
--color-input: #e2e8f0;
--color-ring: #94a3b8;
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-secondary: #f8fafc;
--color-secondary-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
/* Settings variables */
--font-size-base: 16px;
--list-item-height: 48px;
--transition-duration: 0.2s;
}
.dark {
--color-border: #262626;
--color-input: #262626;
--color-ring: #d4d4d4;
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-primary: #fafafa;
--color-primary-foreground: #171717;
--color-secondary: #262626;
--color-secondary-foreground: #fafafa;
--color-muted: #262626;
--color-muted-foreground: #a3a3a3;
--color-accent: #1e3a8a;
--color-accent-foreground: #dbeafe;
}
@theme inline {
--color-border: var(--color-border);
--color-input: var(--color-input);
--color-ring: var(--color-ring);
--color-background: var(--color-background);
--color-foreground: var(--color-foreground);
--color-primary: var(--color-primary);
--color-primary-foreground: var(--color-primary-foreground);
--color-secondary: var(--color-secondary);
--color-secondary-foreground: var(--color-secondary-foreground);
--color-muted: var(--color-muted);
--color-muted-foreground: var(--color-muted-foreground);
--color-accent: var(--color-accent);
--color-accent-foreground: var(--color-accent-foreground);
}
* {
border-color: var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: system-ui, -apple-system, sans-serif;
font-size: var(--font-size-base);
font-feature-settings: "rlig" 1, "calt" 1;
}
/* Minimalist scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-muted-foreground);
}
/* Enhanced Email Content Styling */
.email-content {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-foreground);
max-width: none;
}
.email-content p {
margin: 0.75rem 0;
}
.email-content a {
color: var(--color-primary);
text-decoration: none;
transition: color 0.2s;
}
.email-content a:hover {
text-decoration: underline;
color: var(--color-accent-foreground);
}
.email-content img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 1rem 0;
display: block;
}
.email-content blockquote {
border-left: 3px solid #d1d5db;
padding-left: 1rem;
margin: 1rem 0;
color: #6b7280;
font-style: italic;
}
.dark .email-content blockquote {
border-left-color: #4b5563;
color: #9ca3af;
}
.email-content pre {
background-color: #f5f5f7;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
padding: 1rem;
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
font-size: 0.875rem;
overflow-x: auto;
margin: 1rem 0;
}
.dark .email-content pre {
background-color: #1f2937;
border-color: #374151;
}
.email-content code {
background-color: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
font-size: 0.875rem;
color: #dc2626;
}
.dark .email-content code {
background-color: #374151;
color: #f87171;
}
.email-content h1,
.email-content h2,
.email-content h3,
.email-content h4 {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-weight: 600;
line-height: 1.25;
color: var(--color-foreground);
}
.email-content h1 { font-size: 1.5rem; }
.email-content h2 { font-size: 1.25rem; }
.email-content h3 { font-size: 1.125rem; }
.email-content h4 { font-size: 1rem; }
.email-content ul,
.email-content ol {
margin: 1rem 0;
padding-left: 1.75rem;
}
.email-content li {
margin: 0.375rem 0;
line-height: 1.6;
}
.email-content ul li {
list-style-type: disc;
}
.email-content ol li {
list-style-type: decimal;
}
/* Only style tables that are actual data tables, not layout tables */
.email-content table.data-table,
.email-content table[border="1"] {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
font-size: 0.875rem;
}
.email-content table.data-table th,
.email-content table.data-table td,
.email-content table[border="1"] th,
.email-content table[border="1"] td {
padding: 0.625rem;
border: 1px solid #e5e7eb;
text-align: left;
}
.email-content table.data-table th,
.email-content table[border="1"] th {
background-color: #f9fafb;
font-weight: 600;
color: #374151;
}
.dark .email-content table.data-table th,
.dark .email-content table[border="1"] th {
background-color: #1f2937;
color: #d1d5db;
}
.dark .email-content table.data-table td,
.dark .email-content table[border="1"] td {
border-color: #374151;
}
/* Reset styles for layout tables (commonly used in HTML emails) */
.email-content table {
border-collapse: collapse;
border-spacing: 0;
}
/* Let HTML email's own styles take precedence */
.email-content table:not(.data-table):not([border="1"]) {
border: initial;
}
.email-content table:not(.data-table):not([border="1"]) td,
.email-content table:not(.data-table):not([border="1"]) th {
border: initial;
padding: initial;
}
.email-content hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1.5rem 0;
}
.dark .email-content hr {
border-top-color: #374151;
}
/* Email thread quoted text */
.email-content .quoted-text {
border-left: 3px solid #d1d5db;
padding-left: 1rem;
margin: 1rem 0;
color: #6b7280;
opacity: 0.8;
}
.dark .email-content .quoted-text {
border-left-color: #4b5563;
color: #9ca3af;
}
/* Toast animations */
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
/* Mobile Responsive Utilities */
/* Safe area insets for notched devices (iPhone X+, etc.) */
.safe-area-inset-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-inset-left {
padding-left: env(safe-area-inset-left);
}
.safe-area-inset-right {
padding-right: env(safe-area-inset-right);
}
/* Minimum touch targets (44x44px recommended by Apple HIG) */
.touch-target {
min-height: 44px;
min-width: 44px;
}
/* Prevent text selection on mobile for UI elements */
.no-select {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Smooth transitions for view switching */
.view-transition {
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
/* Hide scrollbar on mobile while keeping functionality */
@media (max-width: 767px) {
.mobile-scroll-hidden::-webkit-scrollbar {
display: none;
}
.mobile-scroll-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
/* Mobile backdrop blur support */
@supports (backdrop-filter: blur(8px)) {
.mobile-backdrop {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
}
/* Prevent overscroll bounce on iOS */
.no-overscroll {
overscroll-behavior: none;
}
/* Slide in from right animation (for mobile views) */
@keyframes slide-in-from-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-from-right {
animation: slide-in-from-right 0.3s ease-out;
}
/* Slide in from left animation (for sidebar) */
@keyframes slide-in-from-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-from-left {
animation: slide-in-from-left 0.3s ease-out;
}
+11
View File
@@ -0,0 +1,11 @@
import { ReactNode } from 'react';
type Props = {
children: ReactNode;
};
// This is the root layout that wraps all pages
// The actual layout with providers and styles is in [locale]/layout.tsx
export default function RootLayout({ children }: Props) {
return children;
}
+469
View File
@@ -0,0 +1,469 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
interface EmailComposerProps {
onSend?: (data: {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
draftId?: string;
}) => void;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
className?: string;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
receivedAt?: string;
};
}
export function EmailComposer({
onSend,
onClose,
onDiscardDraft,
className,
mode = 'compose',
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
if (mode === 'reply') {
return replyTo.from?.[0]?.email || "";
} else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
return [from, originalTo].filter(Boolean).join(", ");
}
return "";
};
const getInitialCc = () => {
if (!replyTo || mode !== 'replyAll') return "";
return replyTo.cc?.map(r => r.email).join(", ") || "";
};
const getInitialSubject = () => {
if (!replyTo?.subject) return "";
if (mode === 'forward') {
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
}
return "";
};
const getInitialBody = () => {
if (!replyTo?.body) return "";
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : "Unknown";
if (mode === 'forward') {
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`;
}
return "";
};
const [to, setTo] = useState(getInitialTo());
const [cc, setCc] = useState(getInitialCc());
const [bcc, setBcc] = useState("");
const [subject, setSubject] = useState(getInitialSubject());
const [body, setBody] = useState(getInitialBody());
const [showCc, setShowCc] = useState(!!getInitialCc());
const [showBcc, setShowBcc] = useState(false);
const [draftId, setDraftId] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { client } = useAuthStore();
// Handle file selection
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!client || !event.target.files) return;
const files = Array.from(event.target.files);
// Add files to attachments list with uploading state
const newAttachments = files.map(file => ({ file, uploading: true }));
setAttachments(prev => [...prev, ...newAttachments]);
// Upload each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const { blobId } = await client.uploadBlob(file);
// Update attachment with blobId
setAttachments(prev =>
prev.map(att =>
att.file === file
? { ...att, blobId, uploading: false }
: att
)
);
} catch (error) {
console.error(`Failed to upload ${file.name}:`, error);
// Mark attachment as failed
setAttachments(prev =>
prev.map(att =>
att.file === file
? { ...att, uploading: false, error: true }
: att
)
);
}
}
// Clear the input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Remove attachment
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
// Auto-save draft functionality
const saveDraft = async (): Promise<string | null> => {
if (!client) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
// Only save if there's some content
if (!toAddresses.length && !subject && !body) {
return null;
}
// Prepare attachments for draft
const uploadedAttachments = attachments
.filter(att => att.blobId && !att.uploading)
.map(att => ({
blobId: att.blobId!,
name: att.file.name,
type: att.file.type,
size: att.file.size,
}));
// Create a hash of current data to compare with last saved
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments });
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
return draftId;
}
setSaveStatus('saving');
try {
const savedDraftId = await client.createDraft(
toAddresses,
subject || "(No subject)",
body,
ccAddresses,
bccAddresses,
draftId || undefined,
uploadedAttachments
);
setDraftId(savedDraftId);
lastSavedDataRef.current = currentData;
setSaveStatus('saved');
// Reset status after 2 seconds
setTimeout(() => setSaveStatus('idle'), 2000);
return savedDraftId;
} catch (error) {
console.error('Failed to save draft:', error);
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
return null;
}
};
// Trigger auto-save when content changes
useEffect(() => {
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Don't auto-save if there's no content
if (!to && !subject && !body) {
return;
}
// Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => {
saveDraft();
}, 2000);
// Cleanup on unmount
return () => {
if (saveTimeoutRef.current) {
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 () => {
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
// Allow sending if we have recipient, subject, and either body text or attachments
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
if (toAddresses.length > 0 && subject && hasContent) {
// Wait for any pending auto-save to complete and get the latest draft ID
let finalDraftId = draftId;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
// saveDraft returns the new draft ID after destroy+create
const savedId = await saveDraft();
if (savedId) {
finalDraftId = savedId;
}
}
onSend?.({
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body,
draftId: finalDraftId || undefined,
});
// Reset form
setTo("");
setCc("");
setBcc("");
setSubject("");
setBody("");
setDraftId(null);
}
};
const handleClose = () => {
// If there's a draft with content, ask user if they want to discard
if (draftId && (to || subject || body)) {
const confirmDiscard = window.confirm(t('discard_draft_confirm'));
if (confirmDiscard) {
// Clear any pending auto-save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Delete the draft if callback is provided
if (onDiscardDraft) {
onDiscardDraft(draftId);
}
onClose?.();
}
} else {
onClose?.();
}
};
return (
<div className={cn("flex flex-col h-full bg-background border rounded-lg", className)}>
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h3 className="font-semibold">New Message</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Save className="w-3 h-3 animate-pulse" />
<span>Saving...</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-3 h-3" />
<span>Draft saved</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1 text-xs text-red-600">
<X className="w-3 h-3" />
<span>Failed to save</span>
</div>
)}
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 flex flex-col">
<div className="space-y-2 px-4 py-3 border-b">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">To:</span>
<Input
type="email"
placeholder="Recipient email addresses (comma separated)"
value={to}
onChange={(e) => setTo(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setShowCc(!showCc)}
className="text-xs"
>
Cc
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBcc(!showBcc)}
className="text-xs"
>
Bcc
</Button>
</div>
</div>
{showCc && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">Cc:</span>
<Input
type="email"
placeholder="Cc recipients (comma separated)"
value={cc}
onChange={(e) => setCc(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
</div>
)}
{showBcc && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">Bcc:</span>
<Input
type="email"
placeholder="Bcc recipients (comma separated)"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">Subject:</span>
<Input
type="text"
placeholder="Subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="flex-1 border-0 focus-visible:ring-0"
/>
</div>
</div>
<div className="flex-1 px-4 py-3">
<textarea
className="w-full h-full resize-none outline-none text-sm"
placeholder="Compose email..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
{/* Attachments display */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t">
<div className="flex flex-wrap gap-2">
{attachments.map((att, index) => (
<div
key={index}
className={cn(
"flex items-center gap-2 px-3 py-1 rounded-md text-sm",
att.error ? "bg-red-50 text-red-700" : "bg-gray-100 text-gray-700"
)}
>
{att.uploading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : att.error ? (
<AlertCircle className="w-3 h-3" />
) : (
<Paperclip className="w-3 h-3" />
)}
<span className="max-w-[200px] truncate">{att.file.name}</span>
<span className="text-xs text-gray-500">
({(att.file.size / 1024).toFixed(1)} KB)
</span>
<button
onClick={() => removeAttachment(index)}
className="ml-1 hover:text-red-600"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t">
<div>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="w-4 h-4 mr-2" />
Attach
</Button>
</div>
<Button onClick={handleSend}>
<Send className="w-4 h-4 mr-2" />
Send
</Button>
</div>
</div>
</div>
);
}
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useTranslations } from "next-intl";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSubMenu,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
Reply,
ReplyAll,
Forward,
Mail,
MailOpen,
Star,
Trash2,
Archive,
FolderInput,
Palette,
X,
Inbox,
Send,
File,
Folder,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Position {
x: number;
y: number;
}
interface EmailContextMenuProps {
email: Email;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
selectedMailbox: string;
isMultiSelect?: boolean;
selectedCount?: number;
// Single email actions
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
}
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
// Get mailbox icon based on role
const getMailboxIcon = (role?: string) => {
switch (role) {
case "inbox":
return Inbox;
case "sent":
return Send;
case "drafts":
return File;
case "trash":
return Trash2;
case "archive":
return Archive;
default:
return Folder;
}
};
// Get current color from email keywords
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
return key.replace("$color:", "");
}
}
return null;
};
export function EmailContextMenu({
email,
position,
isOpen,
onClose,
menuRef,
mailboxes,
selectedMailbox,
isMultiSelect = false,
selectedCount = 1,
onReply,
onReplyAll,
onForward,
onMarkAsRead,
onToggleStar,
onDelete,
onArchive,
onSetColorTag,
onMoveToMailbox,
onBatchMarkAsRead,
onBatchDelete,
onBatchMoveToMailbox,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const currentColor = getCurrentColor(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
const moveTargets = mailboxes.filter(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
);
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
<ContextMenu
ref={menuRef}
isOpen={isOpen}
position={position}
onClose={onClose}
>
{/* Batch header */}
{showBatchActions && (
<ContextMenuHeader>
{t("items_selected", { count: selectedCount })}
</ContextMenuHeader>
)}
{/* Single email actions - Reply, Reply All, Forward */}
{!showBatchActions && (
<>
<ContextMenuItem
icon={Reply}
label={t("reply")}
onClick={() => handleAction(onReply!)}
disabled={!onReply}
/>
<ContextMenuItem
icon={ReplyAll}
label={t("reply_all")}
onClick={() => handleAction(onReplyAll!)}
disabled={!onReplyAll}
/>
<ContextMenuItem
icon={Forward}
label={t("forward")}
onClick={() => handleAction(onForward!)}
disabled={!onForward}
/>
<ContextMenuSeparator />
</>
)}
{/* Mark as read/unread */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
}
/>
{/* Star/Unstar - only for single email */}
{!showBatchActions && (
<ContextMenuItem
icon={Star}
label={isStarred ? t("unstar") : t("star")}
onClick={() => handleAction(onToggleStar!)}
disabled={!onToggleStar}
/>
)}
<ContextMenuSeparator />
{/* Move to submenu */}
{moveTargets.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
{moveTargets.map((mailbox) => {
const Icon = getMailboxIcon(mailbox.role);
return (
<ContextMenuItem
key={mailbox.id}
icon={Icon}
label={mailbox.name}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMoveToMailbox?.(mailbox.id)
: onMoveToMailbox?.(mailbox.id)
)
}
/>
);
})}
</ContextMenuSubMenu>
)}
{/* Archive */}
<ContextMenuItem
icon={Archive}
label={t("archive")}
onClick={() => handleAction(onArchive!)}
disabled={!onArchive}
/>
<ContextMenuSeparator />
{/* Set color submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
<div className="px-3 py-2 flex flex-wrap gap-1.5">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() =>
handleAction(() => onSetColorTag?.(option.value))
}
className={cn(
"w-6 h-6 rounded-full hover:scale-110 transition-transform",
option.color,
currentColor === option.value &&
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
title={option.name}
/>
))}
</div>
{currentColor && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
{/* Delete */}
<ContextMenuItem
icon={Trash2}
label={t("delete")}
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
disabled={showBatchActions ? !onBatchDelete : !onDelete}
destructive
/>
</ContextMenu>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
interface EmailListItemProps {
email: Email;
selected?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
// Color tag mapping - using lighter backgrounds for better readability
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
green: "bg-green-50 dark:bg-green-950/30",
blue: "bg-blue-50 dark:bg-blue-950/30",
purple: "bg-purple-50 dark:bg-purple-950/30",
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
const color = key.replace("$color:", "");
return colorTags[color as keyof typeof colorTags] || null;
}
}
return null;
};
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isImportant = email.keywords?.["$important"];
const sender = email.from?.[0];
const colorTag = getEmailColor(email.keywords);
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
{...dragHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-accent"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-accent/40",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Checkbox with smooth animation */}
<button
onClick={handleCheckboxClick}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
Important
</span>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
interface EmailListProps {
emails: Email[];
selectedEmailId?: string;
onEmailSelect?: (email: Email) => void;
className?: string;
isLoading?: boolean;
// Mobile conversation view handler
onOpenConversation?: (thread: ThreadGroup) => void;
// Context menu actions
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onToggleStar?: (email: Email) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
}
export function EmailList({
emails,
selectedEmailId,
onEmailSelect,
className,
isLoading = false,
onOpenConversation,
onReply,
onReplyAll,
onForward,
onMarkAsRead,
onToggleStar,
onDelete,
onArchive,
onSetColorTag,
onMoveToMailbox,
}: EmailListProps) {
const { client } = useAuthStore();
const {
selectedEmailIds,
selectAllEmails,
clearSelection,
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
mailboxes,
selectedMailbox,
expandedThreadIds,
threadEmailsCache,
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
} = useEmailStore();
// Group emails by thread
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails);
return sortThreadGroups(groups);
}, [emails]);
// Context menu state
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const [isProcessing, setIsProcessing] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
// Loading skeleton component - gentler, no pulsing
const LoadingSkeleton = () => (
<div className="animate-in fade-in duration-200">
{[...Array(8)].map((_, i) => (
<div key={i} className="border-b border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-muted/50 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-muted/50 rounded w-32" />
<div className="h-3 bg-muted/50 rounded w-16" />
</div>
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
<div className="h-3 bg-muted/50 rounded w-full" />
</div>
</div>
</div>
))}
</div>
);
const hasSelection = selectedEmailIds.size > 0;
const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id));
const handleBatchMarkAsRead = async (read: boolean) => {
if (!client || isProcessing) return;
setIsProcessing(true);
try {
await batchMarkAsRead(client, read);
} finally {
setTimeout(() => setIsProcessing(false), 500); // Small delay for visual feedback
}
};
const handleBatchDelete = async () => {
if (!client || isProcessing || !confirm(`Delete ${selectedEmailIds.size} emails?`)) return;
setIsProcessing(true);
try {
await batchDelete(client);
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
// Intersection observer for infinite scroll
const handleLoadMore = useCallback(() => {
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
}
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
// Handle thread expansion and fetch complete thread
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
const isExpanded = expandedThreadIds.has(threadId);
if (!isExpanded && client) {
// Expanding - fetch complete thread emails
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
} else {
// Collapsing - just toggle
toggleThreadExpansion(threadId);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
handleLoadMore();
}
},
{ threshold: 0.1 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [handleLoadMore]);
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Batch Actions Toolbar with smooth transition */}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
<Button
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(true)}
title="Mark as read"
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<MailOpen className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(false)}
title="Mark as unread"
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Mail className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleBatchDelete}
title="Delete"
disabled={isProcessing}
className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
title="Clear selection"
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
Cancel
</Button>
</div>
</div>
</div>
{/* List Header */}
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => allSelected ? clearSelection() : selectAllEmails()}
className={cn(
"p-1 rounded transition-all duration-200",
"hover:bg-muted hover:scale-110",
"active:scale-95",
allSelected && "text-primary"
)}
title={allSelected ? "Deselect all" : "Select all"}
>
{allSelected ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4" />
)}
</button>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'}
</h2>
</div>
</div>
{/* Email List */}
<div className="flex-1 overflow-y-auto bg-background relative">
{/* Loading overlay - shows on top of existing emails */}
{isLoading && emails.length > 0 && (
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading...</span>
</div>
</div>
)}
{/* Show skeleton only on initial load (no emails yet) */}
{isLoading && emails.length === 0 ? (
<LoadingSkeleton />
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" />
<p className="text-base font-medium text-foreground">No emails in this mailbox</p>
<p className="text-sm mt-1 text-muted-foreground">New messages will appear here</p>
</div>
) : (
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
{threadGroups.map((thread) => (
<ThreadListItem
key={thread.threadId}
thread={thread}
isExpanded={expandedThreadIds.has(thread.threadId)}
selectedEmailId={selectedEmailId}
isLoading={isLoadingThread === thread.threadId}
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
onEmailSelect={(email) => onEmailSelect?.(email)}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
/>
))}
{/* Intersection observer target for infinite scroll - always present */}
<div ref={observerTarget} className="py-4 flex justify-center">
{isLoadingMore && hasMoreEmails && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading more emails...</span>
</div>
)}
{!hasMoreEmails && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
No more emails to load
</div>
)}
</div>
</div>
)}
</div>
{/* Context Menu */}
{contextMenu.data && (
<EmailContextMenu
email={contextMenu.data}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
// Single email actions
onReply={() => onReply?.(contextMenu.data!)}
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
onForward={() => onForward?.(contextMenu.data!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
// Batch actions
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
/>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,489 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils";
import {
ArrowLeft,
ChevronDown,
ChevronUp,
Reply,
ReplyAll,
Forward,
Paperclip,
Star,
Download,
Loader2,
FileText,
FileImage,
FileVideo,
FileAudio,
FileArchive,
File,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
interface ThreadConversationViewProps {
thread: ThreadGroup;
emails: Email[];
isLoading?: boolean;
onBack: () => void;
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
}
// Helper function to get file icon based on mime type or extension
const getFileIcon = (name?: string, type?: string) => {
const ext = name?.split('.').pop()?.toLowerCase();
const mimeType = type?.toLowerCase();
if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) {
return FileImage;
}
if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) {
return FileVideo;
}
if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) {
return FileAudio;
}
if (mimeType === 'application/pdf' || ext === 'pdf') {
return FileText;
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
return FileArchive;
}
return File;
};
export function ThreadConversationView({
thread,
emails,
isLoading = false,
onBack,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
onMarkAsRead,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [allowExternalContent, setAllowExternalContent] = useState<Set<string>>(new Set());
// Auto-expand most recent email AND all unread emails when thread opens
useEffect(() => {
if (emails.length > 0) {
const idsToExpand = new Set<string>();
// Always expand most recent
idsToExpand.add(emails[0].id);
// Also expand all unread emails
emails.forEach(email => {
if (!email.keywords?.$seen) {
idsToExpand.add(email.id);
}
});
setExpandedIds(idsToExpand);
}
}, [emails]);
const toggleExpanded = (emailId: string) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(emailId)) {
next.delete(emailId);
} else {
next.add(emailId);
}
return next;
});
};
const toggleAllowExternal = (emailId: string) => {
setAllowExternalContent(prev => {
const next = new Set(prev);
next.add(emailId);
return next;
});
};
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("threads.loading")}</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10">
<button
onClick={onBack}
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1 min-w-0">
<h1 className="font-semibold text-foreground truncate">
{thread.latestEmail.subject || t("email_viewer.no_subject")}
</h1>
<p className="text-sm text-muted-foreground">
{t("threads.messages_other", { count: emails.length })}
</p>
</div>
</div>
{/* Email Cards */}
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-3">
{emails.map((email, index) => (
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
allowExternal={externalContentPolicy === 'allow' || allowExternalContent.has(email.id)}
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onReply={onReply ? () => onReply(email) : undefined}
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
onForward={onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
onMarkAsRead={onMarkAsRead}
/>
))}
</div>
</div>
</div>
);
}
// Individual email card component
interface EmailCardProps {
email: Email;
isExpanded: boolean;
isLatest: boolean;
allowExternal: boolean;
onToggleExpanded: () => void;
onAllowExternal: () => void;
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
}
function EmailCard({
email,
isExpanded,
isLatest: _isLatest,
allowExternal,
onToggleExpanded,
onAllowExternal,
onReply,
onReplyAll,
onForward,
onDownloadAttachment,
onMarkAsRead,
}: EmailCardProps) {
const t = useTranslations();
const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const [hasBlockedContent, setHasBlockedContent] = useState(false);
// Mark as read when email is expanded
useEffect(() => {
// Only trigger if expanded, email is unread, and we have a handler
if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) {
return;
}
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
// Never auto-mark
if (markAsReadDelay === -1) {
return;
}
// Instant mark
if (markAsReadDelay === 0) {
onMarkAsRead(email.id, true);
return;
}
// Delayed mark
const timeout = setTimeout(() => {
onMarkAsRead(email.id, true);
}, markAsReadDelay);
return () => clearTimeout(timeout);
}, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
if (email.bodyValues) {
let useHtmlVersion = false;
let htmlContent = '';
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
}
if (useHtmlVersion && htmlContent) {
let blockedExternalContent = false;
const sanitizeConfig = {
ADD_TAGS: ['style'],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
};
if (!allowExternal) {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.removeAttribute('src');
node.setAttribute('alt', '[Image blocked]');
blockedExternalContent = true;
}
}
if (node.hasAttribute('style')) {
const style = node.getAttribute('style');
if (style && /url\s*\(/i.test(style)) {
const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none');
node.setAttribute('style', cleanStyle);
blockedExternalContent = true;
}
}
});
}
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
DOMPurify.removeHook('afterSanitizeAttributes');
if (blockedExternalContent) {
setHasBlockedContent(true);
}
return { html: sanitized, isHtml: true };
}
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value;
const htmlEscaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
return { html: htmlEscaped, isHtml: false };
}
}
// Fallback to preview
if (email.preview) {
return { html: email.preview.replace(/\n/g, '<br>'), isHtml: false };
}
return { html: "", isHtml: false };
}, [email, allowExternal]);
return (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-l-2 border-l-primary"
)}>
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
className={cn(
"w-full flex items-start gap-3 p-4 text-left transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={cn(
"font-medium truncate",
isUnread ? "text-foreground" : "text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
{email.hasAttachment && (
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)}
</div>
{!isExpanded && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || "No preview available"}
</p>
)}
</div>
<div className="flex-shrink-0 p-1">
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
{/* External content warning */}
{hasBlockedContent && !allowExternal && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("email_viewer.external_content_warning")}
</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAllowExternal();
}}
>
{t("email_viewer.load_external_content")}
</Button>
</div>
)}
{/* Email Body */}
<div className="px-4 py-4">
<div
className={cn(
"prose prose-sm max-w-none dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
</div>
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
return (
<button
key={idx}
onClick={(e) => {
e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
}}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="truncate max-w-[150px]">{attachment.name || 'Attachment'}</span>
<span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)}
</span>
<Download className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
</div>
)}
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
{onReply && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReply();
}}
className="flex-1"
>
<Reply className="w-4 h-4 mr-2" />
{t("email_viewer.reply")}
</Button>
)}
{onReplyAll && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReplyAll();
}}
className="flex-1"
>
<ReplyAll className="w-4 h-4 mr-2" />
{t("email_viewer.reply_all")}
</Button>
)}
{onForward && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onForward();
}}
className="flex-1"
>
<Forward className="w-4 h-4 mr-2" />
{t("email_viewer.forward")}
</Button>
)}
</div>
</div>
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle } from "lucide-react";
interface ThreadEmailItemProps {
email: Email;
selected?: boolean;
isLast?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
export function ThreadEmailItem({
email,
selected,
isLast = false,
onClick,
onContextMenu,
}: ThreadEmailItemProps) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
className={cn(
"relative cursor-pointer transition-all duration-150",
"pl-12 pr-4 py-2.5", // Indented for thread hierarchy
"border-l-2 border-l-transparent",
selected
? "bg-accent border-l-primary"
: "hover:bg-muted/50",
isUnread && !selected && "bg-accent/20",
!isLast && "border-b border-border/30"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
>
<div className="flex items-start gap-3">
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-7 top-1/2 -translate-y-1/2">
<Circle className="w-1.5 h-1.5 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Small Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="sm"
className="flex-shrink-0"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Single line: Sender, indicators, preview, date */}
<div className="flex items-center gap-2">
<span className={cn(
"truncate text-sm flex-shrink-0 max-w-[150px]",
isUnread
? "font-semibold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email?.split('@')[0] || "Unknown"}
</span>
{/* Indicators */}
<div className="flex items-center gap-1 flex-shrink-0">
{isStarred && (
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
)}
{email.hasAttachment && (
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}
</div>
{/* Preview snippet */}
<span className={cn(
"text-sm truncate flex-1 min-w-0",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/70"
)}>
{email.preview || "No preview"}
</span>
{/* Date */}
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-medium"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
</div>
</div>
);
}
+379
View File
@@ -0,0 +1,379 @@
"use client";
import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils";
import { ThreadEmailItem } from "./thread-email-item";
interface ThreadListItemProps {
thread: ThreadGroup;
isExpanded: boolean;
selectedEmailId?: string;
isLoading?: boolean;
expandedEmails?: Email[]; // Full thread emails when expanded
onToggleExpand: () => void;
onEmailSelect: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view
}
// Color tag mapping
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
green: "bg-green-50 dark:bg-green-950/30",
blue: "bg-blue-50 dark:bg-blue-950/30",
purple: "bg-purple-50 dark:bg-purple-950/30",
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
export function ThreadListItem({
thread,
isExpanded,
selectedEmailId,
isLoading = false,
expandedEmails,
onToggleExpand,
onEmailSelect,
onContextMenu,
onOpenConversation,
}: ThreadListItemProps) {
const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
// Get color tag from thread
const threadColor = getThreadColorTag(thread.emails);
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
// Check if latest email is selected
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
// Single email thread - render as regular email, no expand
if (emailCount === 1) {
return (
<SingleEmailItem
email={latestEmail}
selected={selectedEmailId === latestEmail.id}
onClick={() => onEmailSelect(latestEmail)}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
/>
);
}
// Get emails to display when expanded
const emailsToShow = expandedEmails || thread.emails;
const handleHeaderClick = (e: React.MouseEvent) => {
// Mobile: open conversation view instead of inline expansion
if (isMobile && onOpenConversation) {
onOpenConversation(thread);
return;
}
// Desktop: If clicking directly on the expand icon area, toggle expansion
// Otherwise, select the latest email
const target = e.target as HTMLElement;
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
// Clicking on the row selects the latest email but also expands
if (!isExpanded) {
onToggleExpand();
}
onEmailSelect(latestEmail);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, latestEmail);
};
return (
<div className="border-b border-border">
{/* Thread Header (collapsed view) */}
<div
className={cn(
"relative group cursor-pointer transition-all duration-200",
colorTag ? colorTag : (
isSelected
? "bg-accent"
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50"
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Expand/Collapse Button - Hidden on mobile */}
{!isMobile && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground"
)}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{/* Unread indicator */}
{hasUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Participants and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{participantNames.join(", ")}
</span>
{/* Email count badge */}
<span className={cn(
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</div>
{/* Third Line: Preview */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
{/* Expanded Thread Emails - Desktop only */}
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading conversation...
</div>
) : (
emailsToShow.map((email, index) => (
<ThreadEmailItem
key={email.id}
email={email}
selected={email.id === selectedEmailId}
isLast={index === emailsToShow.length - 1}
onClick={() => onEmailSelect(email)}
onContextMenu={onContextMenu}
/>
))
)}
</div>
)}
</div>
);
}
// Single email item (for threads with only 1 email)
function SingleEmailItem({
email,
selected,
onClick,
onContextMenu,
showPreview,
colorTag,
}: {
email: Email;
selected: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
}) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
colorTag ? colorTag : (
selected
? "bg-accent"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Spacer for alignment with thread items */}
<div className="w-6 flex-shrink-0" />
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</div>
{/* Third Line: Preview */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
);
}
+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: _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";
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations();
const modalRef = useRef<HTMLDivElement>(null);
// Close on any key press
useEffect(() => {
const handleKeyDown = () => {
onClose();
};
if (isOpen) {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen, onClose]);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
className={cn(
"bg-background border border-border rounded-lg shadow-xl",
"w-full max-w-2xl max-h-[80vh] overflow-hidden",
"animate-in zoom-in-95 duration-200"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Keyboard className="w-5 h-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">
{t("shortcuts.title")}
</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(80vh-80px)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Navigation Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.navigation")}
</h3>
<div className="space-y-2">
{KEYBOARD_SHORTCUTS.navigation.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Actions Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.actions")}
</h3>
<div className="space-y-2">
{KEYBOARD_SHORTCUTS.actions.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Global Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.global")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{KEYBOARD_SHORTCUTS.global.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Threads Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.threads")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{KEYBOARD_SHORTCUTS.threads.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
</div>
{/* Footer tip */}
<div className="mt-6 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")}
</p>
</div>
</div>
</div>
</div>
);
}
function ShortcutRow({
shortcutKey,
description,
}: {
shortcutKey: string;
description: string;
}) {
// Split keys by " / " to render multiple key badges
const keys = shortcutKey.split(" / ");
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-sm text-muted-foreground">{description}</span>
<div className="flex items-center gap-1.5 ml-4">
{keys.map((key, index) => (
<span key={index}>
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
<kbd
className={cn(
"inline-flex items-center justify-center",
"px-2 py-0.5 text-xs font-mono font-medium",
"bg-muted border border-border rounded",
"text-foreground shadow-sm",
"min-w-[24px]"
)}
>
{key}
</kbd>
</span>
))}
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { cn } from "@/lib/utils";
interface MobileHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
onCompose?: () => void;
onSearch?: () => void;
className?: string;
}
export function MobileHeader({
title,
showBack = false,
onBack,
onCompose,
onSearch,
className,
}: MobileHeaderProps) {
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
const handleLeftAction = () => {
if (showBack && onBack) {
onBack();
} else if (showBack) {
goBack();
} else {
toggleSidebar();
}
};
return (
<header
className={cn(
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
"md:hidden", // Only visible on mobile
className
)}
>
{/* Left action: Menu or Back button */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleLeftAction}
className="h-10 w-10"
aria-label={showBack ? "Go back" : "Toggle menu"}
>
{showBack ? (
<ArrowLeft className="h-5 w-5" />
) : sidebarOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
{/* Title */}
<h1 className="font-semibold text-lg truncate">{title}</h1>
</div>
{/* Right actions */}
<div className="flex items-center gap-1">
{onSearch && (
<Button
variant="ghost"
size="icon"
onClick={onSearch}
className="h-10 w-10"
aria-label="Search"
>
<Search className="h-5 w-5" />
</Button>
)}
{onCompose && (
<Button
variant="ghost"
size="icon"
onClick={onCompose}
className="h-10 w-10 text-primary"
aria-label="Compose"
>
<Plus className="h-5 w-5" />
</Button>
)}
</div>
</header>
);
}
/**
* Viewer header for mobile - shows when viewing an email
*/
interface MobileViewerHeaderProps {
subject?: string;
onBack: () => void;
onDelete?: () => void;
onArchive?: () => void;
className?: string;
}
export function MobileViewerHeader({
subject,
onBack,
onDelete: _onDelete,
onArchive: _onArchive,
className,
}: MobileViewerHeaderProps) {
return (
<header
className={cn(
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
"md:hidden",
className
)}
>
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-10 w-10"
aria-label="Go back"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="flex-1 font-medium text-sm truncate px-2 text-center">
{subject || "(No Subject)"}
</h1>
<div className="flex items-center">
{/* Placeholder for additional actions - kept minimal */}
<div className="w-10" />
</div>
</header>
);
}
+470
View File
@@ -0,0 +1,470 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Inbox,
Send,
File,
Star,
Trash2,
Archive,
PenSquare,
Search,
Menu,
LogOut,
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
Settings,
ChevronUp,
Users,
User,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
interface SidebarProps {
mailboxes: Mailbox[];
selectedMailbox?: string;
onMailboxSelect?: (mailboxId: string) => void;
onCompose?: () => void;
onLogout?: () => void;
onSearch?: (query: string) => void;
quota?: { used: number; total: number } | null;
isPushConnected?: boolean;
className?: string;
}
// Map role to icon
const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean, isExpanded?: boolean, isShared?: boolean, id?: string) => {
const lowerName = name?.toLowerCase() || "";
// Shared folders root node
if (id === 'shared-folders-root') {
return isExpanded ? FolderOpen : Users;
}
// Shared account nodes
if (id?.startsWith('shared-account-')) {
return isExpanded ? FolderOpen : User;
}
// Shared mailboxes (but not virtual nodes)
if (isShared && hasChildren && !id?.startsWith('shared-')) {
return isExpanded ? FolderOpen : Folder;
}
if (hasChildren) {
// For folders with children, return open/closed folder icon
return isExpanded ? FolderOpen : Folder;
}
if (role === "inbox" || lowerName.includes("inbox")) return Inbox;
if (role === "sent" || lowerName.includes("sent")) return Send;
if (role === "drafts" || lowerName.includes("draft")) return File;
if (role === "trash" || lowerName.includes("trash")) return Trash2;
if (role === "archive" || lowerName.includes("archive")) return Archive;
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
return Inbox; // Default icon
};
// Component for rendering a single mailbox node with its children
function MailboxTreeItem({
node,
selectedMailbox,
expandedFolders,
onMailboxSelect,
onToggleExpand,
isCollapsed,
}: {
node: MailboxNode;
selectedMailbox: string;
expandedFolders: Set<string>;
onMailboxSelect?: (id: string) => void;
onToggleExpand: (id: string) => void;
isCollapsed: boolean;
}) {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
const indentPixels = node.depth * 16; // 16px per depth level
const isVirtualNode = node.id.startsWith('shared-'); // Virtual nodes for shared folder organization
// Drag and drop functionality
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node,
});
return (
<>
<div
{...(globalDragging ? dropHandlers : {})}
className={cn(
"group w-full flex items-center px-2 py-1 text-sm transition-all duration-200",
selectedMailbox === node.id
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
node.depth === 0 && "font-medium", // Root folders are slightly bolder
// Drop target visual feedback
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
)}
>
{/* Expand/Collapse Chevron */}
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
style={{ marginLeft: indentPixels }}
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
)}
{/* Mailbox Button */}
<button
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode}
className={cn(
"flex-1 flex items-center text-left py-1 px-1 rounded",
"transition-colors duration-150",
isVirtualNode && "cursor-default"
)}
style={{
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
}}
title={isCollapsed ? node.name : undefined}
>
<Icon className={cn(
"w-4 h-4 mr-2 flex-shrink-0 transition-colors",
hasChildren && isExpanded && "text-primary",
selectedMailbox === node.id && "text-accent-foreground",
!hasChildren && node.depth > 0 && "text-muted-foreground",
node.isShared && "text-blue-500" // Shared folders in blue
)} />
{!isCollapsed && (
<>
<span className="flex-1 truncate">{node.name}</span>
{node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
{node.unreadEmails}
</span>
)}
</>
)}
</button>
</div>
{/* Render children if expanded */}
{hasChildren && isExpanded && !isCollapsed && (
<div className="relative">
{node.children.map((child) => (
<MailboxTreeItem
key={child.id}
node={child}
selectedMailbox={selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
isCollapsed={isCollapsed}
/>
))}
</div>
)}
</>
);
}
export function Sidebar({
mailboxes = [],
selectedMailbox = "",
onMailboxSelect,
onCompose,
onLogout,
onSearch,
quota,
isPushConnected = false,
className,
}: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [showMenu, setShowMenu] = useState(false);
const t = useTranslations('sidebar');
const params = useParams();
const router = useRouter();
// Load expanded folders from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes');
if (stored) {
try {
const parsed = JSON.parse(stored);
setExpandedFolders(new Set(parsed));
} catch (e) {
console.error('Failed to parse expanded mailboxes:', e);
}
} else {
// By default, expand root folders that have children
const tree = buildMailboxTree(mailboxes);
const defaultExpanded = tree
.filter(node => node.children.length > 0)
.map(node => node.id);
setExpandedFolders(new Set(defaultExpanded));
}
}, [mailboxes]);
// Save expanded folders to localStorage when changed
const handleToggleExpand = (mailboxId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(mailboxId)) {
next.delete(mailboxId);
} else {
next.add(mailboxId);
}
localStorage.setItem('expandedMailboxes', JSON.stringify(Array.from(next)));
return next;
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim() && onSearch) {
onSearch(searchQuery);
}
};
// Build hierarchical mailbox tree
const mailboxTree = buildMailboxTree(mailboxes);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!selectedMailbox || isCollapsed) return;
// Find the selected node in the tree
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
for (const node of nodes) {
if (node.id === selectedMailbox) return node;
const found = findNode(node.children);
if (found) return found;
}
return null;
};
const selectedNode = findNode(mailboxTree);
if (!selectedNode) return;
// Handle arrow keys for expand/collapse
if (e.key === 'ArrowRight' && selectedNode.children.length > 0) {
// Expand folder
if (!expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox);
}
} else if (e.key === 'ArrowLeft' && selectedNode.children.length > 0) {
// Collapse folder
if (expandedFolders.has(selectedMailbox)) {
handleToggleExpand(selectedMailbox);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]);
return (
<div
className={cn(
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
isCollapsed ? "w-16" : "w-64",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<Menu className="w-5 h-5" />
</Button>
{!isCollapsed && (
<Button onClick={onCompose} className="ml-2 flex-1">
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
)}
</div>
{/* Search */}
{!isCollapsed && (
<div className="px-4 py-3">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
data-search-input
/>
</form>
</div>
)}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto">
<div className="py-1">
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{/* Render hierarchical mailbox tree */}
{mailboxTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
/>
))}
</>
)}
</div>
</div>
{/* Footer */}
{!isCollapsed && (
<>
{/* Sliding Menu Panel */}
<div className={cn(
"absolute bottom-0 left-0 right-0 bg-background border-t border-border z-10 shadow-lg",
"transform transition-all duration-300 ease-out",
showMenu ? "-translate-y-12" : "translate-y-full"
)}>
<div className="py-2">
{/* Storage Info */}
{quota && quota.total > 0 && (
<div className="px-4 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t("storage")}</span>
<span className="text-foreground">
{formatFileSize(quota.used)} / {formatFileSize(quota.total)}
</span>
</div>
<div className="mt-1 w-full bg-muted rounded-full h-1">
<div
className="bg-primary h-1 rounded-full"
style={{ width: `${Math.min((quota.used / quota.total) * 100, 100)}%` }}
/>
</div>
</div>
)}
<div className="border-t border-border mt-2 pt-2">
{/* Settings */}
<button
onClick={() => router.push(`/${params.locale}/settings`)}
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
>
<span className="flex items-center gap-2">
<Settings className="w-4 h-4" />
{t("settings")}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Sign Out */}
{onLogout && (
<button
onClick={onLogout}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
{t("sign_out")}
</button>
)}
</div>
</div>
</div>
{/* Menu Toggle Button */}
<div className="border-t border-border relative">
<button
onClick={() => setShowMenu(!showMenu)}
className={cn(
"w-full px-4 py-3 flex items-center justify-between",
"hover:bg-muted transition-colors",
"text-sm text-foreground"
)}
>
<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",
showMenu ? "" : "rotate-180"
)} />
</button>
</div>
</>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
useEffect(() => {
initializeTheme();
}, [initializeTheme]);
return <>{children}</>;
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { SettingsSection, SettingItem } from './settings-section';
import { formatFileSize } from '@/lib/utils';
export function AccountSettings() {
const t = useTranslations('settings.account');
const { username, serverUrl } = useAuthStore();
const { quota } = useEmailStore();
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
</SettingItem>
)}
</SettingsSection>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
export function AdvancedSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
const settingsJson = exportSettings();
const blob = new Blob([settingsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `webmail-settings-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleImport = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target?.result as string;
const success = importSettings(json);
if (success) {
alert(t('../../settings.import_success'));
} else {
alert(t('../../settings.import_error'));
}
};
reader.readAsText(file);
};
const handleReset = () => {
if (showResetConfirm) {
resetToDefaults();
setShowResetConfirm(false);
alert(t('../../settings.save_success'));
} else {
setShowResetConfirm(true);
setTimeout(() => setShowResetConfirm(false), 5000);
}
};
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
{/* Export Settings */}
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
{/* Import Settings */}
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
</Button>
</>
</SettingItem>
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const { theme, setTheme } = useThemeStore();
const { fontSize, listDensity, animationsEnabled, updateSetting } = useSettingsStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup
value={theme}
onChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
options={[
{ value: 'light', label: t('theme.light') },
{ value: 'dark', label: t('theme.dark') },
{ value: 'system', label: t('theme.system') },
]}
/>
</SettingItem>
{/* Font Size */}
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
<RadioGroup
value={fontSize}
onChange={(value) => updateSetting('fontSize', value as 'small' | 'medium' | 'large')}
options={[
{ value: 'small', label: t('font_size.small') },
{ value: 'medium', label: t('font_size.medium') },
{ value: 'large', label: t('font_size.large') },
]}
/>
</SettingItem>
{/* List Density */}
<SettingItem label={t('list_density.label')} description={t('list_density.description')}>
<RadioGroup
value={listDensity}
onChange={(value) =>
updateSetting('listDensity', value as 'compact' | 'regular' | 'comfortable')
}
options={[
{ value: 'compact', label: t('list_density.compact') },
{ value: 'regular', label: t('list_density.regular') },
{ value: 'comfortable', label: t('list_density.comfortable') },
]}
/>
</SettingItem>
{/* Animations */}
<SettingItem label={t('animations.label')} description={t('animations.description')}>
<ToggleSwitch
checked={animationsEnabled}
onChange={(checked) => updateSetting('animationsEnabled', checked)}
/>
</SettingItem>
</SettingsSection>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
const {
markAsReadDelay,
deleteAction,
showPreview,
emailsPerPage,
externalContentPolicy,
updateSetting,
} = useSettingsStore();
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')}>
<Select
value={markAsReadDelay.toString()}
onChange={(value) => updateSetting('markAsReadDelay', parseInt(value))}
options={[
{ value: '0', label: t('mark_read.instant') },
{ value: '3000', label: t('mark_read.delay_3s') },
{ value: '5000', label: t('mark_read.delay_5s') },
{ value: '-1', label: t('mark_read.never') },
]}
/>
</SettingItem>
{/* Delete Action */}
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
/>
</SettingItem>
{/* Show Preview */}
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
{/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
<Select
value={emailsPerPage.toString()}
onChange={(value) => updateSetting('emailsPerPage', parseInt(value))}
options={[
{ value: '25', label: t('emails_per_page.25') },
{ value: '50', label: t('emails_per_page.50') },
{ value: '100', label: t('emails_per_page.100') },
]}
/>
</SettingItem>
{/* External Content */}
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
</SettingsSection>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { ReactNode } from 'react';
interface SettingsSectionProps {
title: string;
description?: string;
children: ReactNode;
}
export function SettingsSection({ title, description, children }: SettingsSectionProps) {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="space-y-4">{children}</div>
</div>
);
}
interface SettingItemProps {
label: string;
description?: string;
children: ReactNode;
}
export function SettingItem({ label, description, children }: SettingItemProps) {
return (
<div className="flex items-start justify-between py-3 border-b border-border last:border-0">
<div className="flex-1 pr-4">
<label className="text-sm font-medium text-foreground">{label}</label>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
${checked ? 'bg-primary' : 'bg-muted'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-background transition-transform
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
);
}
interface RadioGroupProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
return (
<div className="flex gap-2">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`
px-3 py-1.5 text-xs rounded transition-colors
${
value === option.value
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-foreground'
}
`}
>
{option.label}
</button>
))}
</div>
);
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function Select({ value, onChange, options }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
interface AvatarProps {
name?: string;
email?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
const getInitials = () => {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
if (email) {
return email[0].toUpperCase();
}
return "?";
};
const getBackgroundColor = () => {
const str = name || email || "";
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 50%)`;
};
const sizeClasses = {
sm: "w-8 h-8 text-xs",
md: "w-10 h-10 text-sm",
lg: "w-12 h-12 text-base",
};
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-semibold text-white",
sizeClasses[size],
className
)}
style={{ backgroundColor: getBackgroundColor() }}
title={name || email}
>
{getInitials()}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "ghost" | "outline" | "destructive";
size?: "sm" | "md" | "lg" | "icon";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md font-medium transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
{
default:
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow",
ghost: "hover:bg-accent hover:text-accent-foreground",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
}[variant],
{
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 py-2",
lg: "h-11 px-8",
icon: "h-10 w-10",
}[size],
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button };
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { forwardRef, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
interface Position {
x: number;
y: number;
}
interface ContextMenuProps {
isOpen: boolean;
position: Position;
onClose: () => void;
children: React.ReactNode;
}
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose: _onClose, children }, ref) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || !isOpen) return null;
return createPortal(
<div
ref={ref}
className={cn(
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
style={{
left: position.x,
top: position.y,
}}
role="menu"
aria-orientation="vertical"
>
<div className="py-1">
{children}
</div>
</div>,
document.body
);
}
);
ContextMenu.displayName = "ContextMenu";
interface ContextMenuItemProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
shortcut?: string;
}
export function ContextMenuItem({
icon: Icon,
label,
onClick,
disabled = false,
destructive = false,
shortcut,
}: ContextMenuItemProps) {
return (
<button
role="menuitem"
disabled={disabled}
className={cn(
"w-full px-3 py-2 text-sm text-left flex items-center gap-2",
"transition-colors duration-100",
"focus:outline-none focus:bg-muted",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "hover:bg-muted cursor-pointer",
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus:bg-destructive/10"
)}
onClick={(e) => {
if (disabled) return;
e.stopPropagation();
onClick();
}}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground ml-auto">{shortcut}</span>
)}
</button>
);
}
export function ContextMenuSeparator() {
return <div className="h-px bg-border my-1" role="separator" />;
}
interface ContextMenuSubMenuProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
children: React.ReactNode;
}
export function ContextMenuSubMenu({
icon: Icon,
label,
children,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && itemRef.current) {
const rect = itemRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
// Check if submenu would overflow right edge
if (rect.right + 200 > viewportWidth - 10) {
setSubMenuPosition("left");
} else {
setSubMenuPosition("right");
}
}
}, [isOpen]);
return (
<div
ref={itemRef}
className="relative"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<div
className={cn(
"w-full px-3 py-2 text-sm flex items-center gap-2",
"transition-colors duration-100 cursor-pointer",
"hover:bg-muted"
)}
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
{isOpen && (
<div
ref={subMenuRef}
className={cn(
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100",
subMenuPosition === "right" ? "left-full ml-1" : "right-full mr-1"
)}
role="menu"
>
<div className="py-1 max-h-[300px] overflow-y-auto">
{children}
</div>
</div>
)}
</div>
);
}
interface ContextMenuHeaderProps {
children: React.ReactNode;
}
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
return (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
{children}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground",
"hover:border-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useParams, usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { locales } from '@/i18n/request';
export function LanguageSwitcher({ className }: { className?: string }) {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const t = useTranslations('language');
const currentLocale = params.locale as string;
const handleLanguageChange = (newLocale: string) => {
// Get the path without the locale prefix
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
// Navigate to the same page with the new locale
router.push(`/${newLocale}${pathWithoutLocale}`);
};
return (
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
{locales.map((locale) => (
<button
key={locale}
onClick={() => handleLanguageChange(locale)}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
"text-foreground",
currentLocale === locale
? "bg-background shadow-sm font-medium"
: "hover:bg-accent/50"
)}
title={t(locale === 'en' ? 'english' : 'french')}
>
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
<span className="hidden sm:inline">
{locale.toUpperCase()}
</span>
</button>
))}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useEffect } from "react";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
export type ToastType = "success" | "error" | "info" | "warning";
export interface Toast {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
onClick?: () => void;
}
interface ToastProps {
toast: Toast;
onClose: (id: string) => void;
}
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
warning: AlertTriangle,
};
const styles = {
success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
};
export function ToastItem({ toast, onClose }: ToastProps) {
const Icon = icons[toast.type];
useEffect(() => {
if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => {
onClose(toast.id);
}, toast.duration);
return () => clearTimeout(timer);
}
}, [toast, onClose]);
return (
<div
className={cn(
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
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">
<h4 className="font-medium">{toast.title}</h4>
{toast.message && (
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose(toast.id);
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
))}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import { Email } from "@/lib/jmap/types";
interface DragDropState {
isDragging: boolean;
draggedEmails: Email[];
dragCount: number;
sourceMailboxId: string | null;
}
interface DragDropContextValue extends DragDropState {
startDrag: (emails: Email[], sourceMailboxId: string) => void;
endDrag: () => void;
}
const initialState: DragDropState = {
isDragging: false,
draggedEmails: [],
dragCount: 0,
sourceMailboxId: null,
};
const DragDropContext = createContext<DragDropContextValue | null>(null);
export function DragDropProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DragDropState>(initialState);
const startDrag = useCallback((emails: Email[], sourceMailboxId: string) => {
setState({
isDragging: true,
draggedEmails: emails,
dragCount: emails.length,
sourceMailboxId,
});
}, []);
const endDrag = useCallback(() => {
setState(initialState);
}, []);
return (
<DragDropContext.Provider value={{ ...state, startDrag, endDrag }}>
{children}
</DragDropContext.Provider>
);
}
export function useDragDropContext() {
const context = useContext(DragDropContext);
if (!context) {
throw new Error("useDragDropContext must be used within DragDropProvider");
}
return context;
}
+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",
],
},
];
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
interface Position {
x: number;
y: number;
}
interface ContextMenuState<T> {
isOpen: boolean;
position: Position;
data: T | null;
}
interface UseContextMenuReturn<T> {
contextMenu: ContextMenuState<T>;
openContextMenu: (e: React.MouseEvent, data: T) => void;
closeContextMenu: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
}
const MENU_WIDTH = 200;
const MENU_HEIGHT = 320; // Approximate max height
export function useContextMenu<T>(): UseContextMenuReturn<T> {
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
isOpen: false,
position: { x: 0, y: 0 },
data: null,
});
const menuRef = useRef<HTMLDivElement | null>(null);
const calculatePosition = useCallback((clientX: number, clientY: number): Position => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = clientX;
let y = clientY;
// Adjust for right edge
if (x + MENU_WIDTH > viewportWidth - 10) {
x = viewportWidth - MENU_WIDTH - 10;
}
// Adjust for bottom edge
if (y + MENU_HEIGHT > viewportHeight - 10) {
y = viewportHeight - MENU_HEIGHT - 10;
}
// Ensure minimum position
x = Math.max(10, x);
y = Math.max(10, y);
return { x, y };
}, []);
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
e.preventDefault();
e.stopPropagation();
const position = calculatePosition(e.clientX, e.clientY);
setContextMenu({
isOpen: true,
position,
data,
});
}, [calculatePosition]);
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({
...prev,
isOpen: false,
}));
}, []);
// Close on escape key, click outside, and scroll
useEffect(() => {
if (!contextMenu.isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeContextMenu();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeContextMenu();
}
};
const handleScroll = () => {
closeContextMenu();
};
const handleBlur = () => {
closeContextMenu();
};
// Add listeners with a slight delay to prevent immediate closing
const timeoutId = setTimeout(() => {
document.addEventListener("keydown", handleEscape);
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("scroll", handleScroll, true);
window.addEventListener("blur", handleBlur);
}, 0);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("keydown", handleEscape);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("blur", handleBlur);
};
}, [contextMenu.isOpen, closeContextMenu]);
return {
contextMenu,
openContextMenu,
closeContextMenu,
menuRef,
};
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useCallback, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
interface UseEmailDragOptions {
email: Email;
sourceMailboxId: string;
}
interface UseEmailDragReturn {
dragHandlers: {
draggable: boolean;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
};
isDragging: boolean;
}
function createDragPreview(count: number): HTMLElement {
const preview = document.createElement("div");
preview.className = "drag-preview";
preview.style.cssText = `
position: fixed;
top: -9999px;
left: 0;
padding: 8px 16px;
background-color: var(--color-primary, #3b82f6);
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 14px;
font-weight: 500;
z-index: 9999;
white-space: nowrap;
pointer-events: none;
`;
preview.textContent = count === 1 ? "1 email" : `${count} emails`;
document.body.appendChild(preview);
return preview;
}
export function useEmailDrag({ email, sourceMailboxId }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailIds, emails } = useEmailStore();
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
// - If current email is selected, drag all selected
// - Otherwise, drag only this email
const isSelected = selectedEmailIds.has(email.id);
const emailsToDrag = isSelected
? emails.filter(em => selectedEmailIds.has(em.id))
: [email];
// Set data transfer
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(
"application/x-email-ids",
JSON.stringify(emailsToDrag.map(em => em.id))
);
e.dataTransfer.setData(
"text/plain",
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
);
// Create custom drag image
const dragPreview = createDragPreview(emailsToDrag.length);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
// Clean up preview after drag starts (browser keeps a snapshot)
requestAnimationFrame(() => {
dragPreview.remove();
});
startDrag(emailsToDrag, sourceMailboxId);
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag]);
const handleDragEnd = useCallback(() => {
endDrag();
}, [endDrag]);
// Check if this specific email is being dragged
const isThisEmailDragging = isDragging && draggedEmails.some(em => em.id === email.id);
return {
dragHandlers: {
draggable: true,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
},
isDragging: isThisEmailDragging,
};
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
import { Email } from "@/lib/jmap/types";
export interface KeyboardShortcutHandlers {
// Navigation
onNextEmail?: () => void;
onPreviousEmail?: () => void;
onOpenEmail?: () => void;
onCloseEmail?: () => void;
// Email actions
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onToggleStar?: () => void;
onArchive?: () => void;
onDelete?: () => void;
onMarkAsUnread?: () => void;
onMarkAsRead?: () => void;
// Global actions
onCompose?: () => void;
onFocusSearch?: () => void;
onShowHelp?: () => void;
onRefresh?: () => void;
// Selection
onSelectAll?: () => void;
onDeselectAll?: () => void;
// Thread actions
onToggleThreadExpansion?: () => void;
}
export interface UseKeyboardShortcutsOptions {
enabled?: boolean;
emails: Email[];
selectedEmailId?: string;
handlers: KeyboardShortcutHandlers;
}
// Check if user is typing in an input field
function isInputFocused(): boolean {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
return isInput || isContentEditable;
}
export function useKeyboardShortcuts({
enabled = true,
emails,
selectedEmailId,
handlers,
}: UseKeyboardShortcutsOptions) {
const handlersRef = useRef(handlers);
// Keep handlers ref updated
useEffect(() => {
handlersRef.current = handlers;
}, [handlers]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (isInputFocused()) return;
const h = handlersRef.current;
const key = event.key.toLowerCase();
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
// Shortcuts that work with modifiers
if (event.ctrlKey || event.metaKey) {
switch (key) {
case "a":
// Ctrl/Cmd + A: Select all
event.preventDefault();
h.onSelectAll?.();
return;
}
}
// Shortcuts that should NOT work with modifiers
if (hasModifier) return;
switch (key) {
// Navigation
case "j":
case "arrowdown":
event.preventDefault();
h.onNextEmail?.();
break;
case "k":
case "arrowup":
event.preventDefault();
h.onPreviousEmail?.();
break;
case "enter":
case "o":
if (selectedEmailId) {
event.preventDefault();
h.onOpenEmail?.();
}
break;
case "escape":
event.preventDefault();
h.onCloseEmail?.();
h.onDeselectAll?.();
break;
// Email actions (only when email is selected)
case "r":
if (selectedEmailId) {
event.preventDefault();
if (event.shiftKey) {
h.onReplyAll?.();
} else {
h.onReply?.();
}
}
break;
case "a":
if (selectedEmailId) {
event.preventDefault();
h.onReplyAll?.();
}
break;
case "f":
if (selectedEmailId) {
event.preventDefault();
h.onForward?.();
}
break;
case "s":
if (selectedEmailId) {
event.preventDefault();
h.onToggleStar?.();
}
break;
case "e":
if (selectedEmailId) {
event.preventDefault();
h.onArchive?.();
}
break;
case "#":
case "delete":
case "backspace":
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
event.preventDefault();
h.onDelete?.();
}
break;
case "u":
if (selectedEmailId) {
event.preventDefault();
h.onMarkAsUnread?.();
}
break;
case "i":
if (selectedEmailId && event.shiftKey) {
event.preventDefault();
h.onMarkAsRead?.();
}
break;
// Global actions
case "c":
event.preventDefault();
h.onCompose?.();
break;
case "/":
event.preventDefault();
h.onFocusSearch?.();
break;
case "?":
event.preventDefault();
h.onShowHelp?.();
break;
case "g":
if (event.shiftKey) {
event.preventDefault();
h.onRefresh?.();
}
break;
// Thread actions
case "x":
if (selectedEmailId) {
event.preventDefault();
h.onToggleThreadExpansion?.();
}
break;
}
},
[selectedEmailId]
);
useEffect(() => {
if (!enabled) return;
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [enabled, handleKeyDown]);
// Helper to get next/previous email
const getAdjacentEmailIndex = useCallback(
(direction: "next" | "previous"): number => {
if (emails.length === 0) return -1;
if (!selectedEmailId) {
// If no email selected, select first (for next) or last (for previous)
return direction === "next" ? 0 : emails.length - 1;
}
const currentIndex = emails.findIndex((e) => e.id === selectedEmailId);
if (currentIndex === -1) return direction === "next" ? 0 : emails.length - 1;
if (direction === "next") {
return currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
} else {
return currentIndex > 0 ? currentIndex - 1 : currentIndex;
}
},
[emails, selectedEmailId]
);
return { getAdjacentEmailIndex };
}
// Shortcut definitions for the help modal
export const KEYBOARD_SHORTCUTS = {
navigation: [
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
{ key: "Esc", description: "shortcuts.navigation.close_email" },
],
actions: [
{ key: "r", description: "shortcuts.actions.reply" },
{ key: "R / a", description: "shortcuts.actions.reply_all" },
{ key: "f", description: "shortcuts.actions.forward" },
{ key: "s", description: "shortcuts.actions.star" },
{ key: "e", description: "shortcuts.actions.archive" },
{ key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
],
global: [
{ key: "c", description: "shortcuts.global.compose" },
{ key: "/", description: "shortcuts.global.search" },
{ key: "?", description: "shortcuts.global.help" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
],
threads: [
{ key: "x", description: "shortcuts.threads.expand_collapse" },
],
} as const;
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useCallback, useState, DragEvent } from "react";
import { Mailbox } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store";
interface UseMailboxDropOptions {
mailbox: Mailbox;
onDropComplete?: () => void;
}
interface UseMailboxDropReturn {
dropHandlers: {
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDragEnter: (e: DragEvent<HTMLDivElement>) => void;
onDragLeave: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
};
isDropTarget: boolean;
isValidDropTarget: boolean;
isInvalidDropTarget: boolean;
}
export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
const isValidTarget = useCallback(() => {
if (!isDragging) return false;
// Cannot drop on same mailbox
if (mailbox.id === sourceMailboxId) return false;
// Check if mailbox accepts items
if (!mailbox.myRights?.mayAddItems) return false;
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
// For shared mailboxes, check account compatibility
if (mailbox.isShared && draggedEmails[0]) {
// Get the source mailbox's account ID from the store
const mailboxes = useEmailStore.getState().mailboxes;
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
// Cross-account moves are not supported
if (sourceMb?.accountId !== mailbox.accountId) {
return false;
}
}
return true;
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (isValidTarget()) {
e.dataTransfer.dropEffect = "move";
} else {
e.dataTransfer.dropEffect = "none";
}
}, [isValidTarget]);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsOver(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// Only leave if actually leaving the element (not entering a child)
const relatedTarget = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(relatedTarget)) {
setIsOver(false);
}
}, []);
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsOver(false);
if (!client || !isValidTarget()) {
endDrag();
return;
}
try {
const emailIdsJson = e.dataTransfer.getData("application/x-email-ids");
if (!emailIdsJson) {
endDrag();
return;
}
const emailIds: string[] = JSON.parse(emailIdsJson);
// Get the destination mailbox ID (use originalId for shared folders)
const destinationId = mailbox.originalId || mailbox.id;
// Move emails one by one (store handles counter updates)
for (const emailId of emailIds) {
await moveToMailbox(client, emailId, destinationId);
}
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
clearSelection();
}
// Refresh the current mailbox view
await fetchEmails(client, selectedMailbox);
// Show success toast
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
}
onDropComplete?.();
} catch (error) {
console.error("Failed to move emails:", error);
toast.error("Move failed", "Could not move emails to the selected folder");
} finally {
endDrag();
}
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete]);
const valid = isValidTarget();
return {
dropHandlers: {
onDragOver: handleDragOver,
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
isDropTarget: isOver && isDragging,
isValidDropTarget: isOver && valid,
isInvalidDropTarget: isOver && isDragging && !valid,
};
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState, useEffect } from "react";
import { useUIStore } from "@/stores/ui-store";
// Tailwind v4 breakpoints
const BREAKPOINTS = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
} as const;
/**
* SSR-safe media query hook
* Returns false during SSR to prevent hydration mismatch
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [query]);
return matches;
}
/**
* Hook to detect device type and sync with UI store
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
*/
export function useDeviceDetection() {
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
const isTabletQuery = useMediaQuery(
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
);
const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
useEffect(() => {
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType]);
return { isMobile, isTablet, isDesktop };
}
/**
* Convenience hooks for specific breakpoints
*/
export function useIsMobile() {
return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
}
export function useIsTablet() {
return useMediaQuery(
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
);
}
export function useIsDesktop() {
return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
}
export function useBreakpoint(breakpoint: keyof typeof BREAKPOINTS) {
return useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
}
+27
View File
@@ -0,0 +1,27 @@
import { getRequestConfig } from 'next-intl/server';
export const locales = ['en', 'fr'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
export default getRequestConfig(async ({ requestLocale }) => {
// Get the locale from the request or use default
let locale = await requestLocale || defaultLocale;
// Validate that the incoming `locale` parameter is valid
if (!(locales as readonly string[]).includes(locale)) {
locale = defaultLocale;
}
// Use static imports for better compatibility
const messages = locale === 'fr'
? (await import('../locales/fr/common.json')).default
: (await import('../locales/en/common.json')).default;
return {
locale,
messages,
timeZone: 'Europe/Paris',
now: new Date()
};
});
+77
View File
@@ -0,0 +1,77 @@
import { useSettingsStore } from '@/stores/settings-store';
/**
* Debug logger that respects the debugMode setting.
* Use this instead of console.log for conditional debug output.
*/
export const debug = {
/**
* Log a debug message (only when debugMode is enabled)
*/
log: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.log('[DEBUG]', ...args);
}
},
/**
* Log a warning message (only when debugMode is enabled)
*/
warn: (...args: unknown[]) => {
if (useSettingsStore.getState().debugMode) {
console.warn('[DEBUG]', ...args);
}
},
/**
* Log an error message (always logs, regardless of debugMode)
*/
error: (...args: unknown[]) => {
console.error('[ERROR]', ...args);
},
/**
* Start a collapsed console group (only when debugMode is enabled)
*/
group: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.group(`[DEBUG] ${label}`);
}
},
/**
* End a console group (only when debugMode is enabled)
*/
groupEnd: () => {
if (useSettingsStore.getState().debugMode) {
console.groupEnd();
}
},
/**
* Start a performance timer (only when debugMode is enabled)
*/
time: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.time(`[DEBUG] ${label}`);
}
},
/**
* End a performance timer (only when debugMode is enabled)
*/
timeEnd: (label: string) => {
if (useSettingsStore.getState().debugMode) {
console.timeEnd(`[DEBUG] ${label}`);
}
},
/**
* Log a table (only when debugMode is enabled)
*/
table: (data: unknown) => {
if (useSettingsStore.getState().debugMode) {
console.table(data);
}
}
};
+230
View File
@@ -0,0 +1,230 @@
import { AuthenticationResults } from './jmap/types';
/**
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
*/
export function parseAuthenticationResults(header: string): AuthenticationResults {
const results: AuthenticationResults = {};
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
type DmarcResult = 'pass' | 'fail' | 'none';
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
// Parse SPF
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
if (spfMatch) {
results.spf = {
result: spfMatch[1] as SpfResult,
domain: spfMatch[2]
};
}
// Parse DKIM
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s]+))?(?:\s+header\.s=([^\s]+))?/);
if (dkimMatch) {
results.dkim = {
result: dkimMatch[1] as DkimResult,
domain: dkimMatch[2],
selector: dkimMatch[3]
};
}
// Parse DMARC
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s]+))?(?:\s+policy\.dmarc=(\w+))?/);
if (dmarcMatch) {
results.dmarc = {
result: dmarcMatch[1] as DmarcResult,
domain: dmarcMatch[2],
policy: dmarcMatch[3] as DmarcPolicy | undefined
};
}
// Parse IP reverse lookup
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
if (iprevMatch) {
results.iprev = {
result: iprevMatch[1] as 'pass' | 'fail',
ip: iprevMatch[2]
};
}
return results;
}
/**
* Parse spam score from X-Spam-Result or X-Spam-Status headers
*/
export function parseSpamScore(header: string): { score: number; status: string } | null {
// Try X-Spam-Status format: "No, score=-0.25"
const statusMatch = header.match(/^(Yes|No),?\s+score=([-\d.]+)/i);
if (statusMatch) {
return {
status: statusMatch[1].toLowerCase(),
score: parseFloat(statusMatch[2])
};
}
// Try to extract just the score
const scoreMatch = header.match(/score[=:]?\s*([-\d.]+)/i);
if (scoreMatch) {
const score = parseFloat(scoreMatch[1]);
return {
score,
status: score > 5 ? 'spam' : 'ham'
};
}
return null;
}
/**
* Parse Received headers to extract mail routing path
*/
interface ReceivedHeaderInfo {
from: string;
by: string;
timestamp?: string;
protocol?: string;
id?: string;
}
export function parseReceivedHeaders(headers: string[]): ReceivedHeaderInfo[] {
const path: ReceivedHeaderInfo[] = [];
for (const header of headers) {
const fromMatch = header.match(/from\s+([^\s]+)(?:\s+\([^)]+\))?/);
const byMatch = header.match(/by\s+([^\s]+)/);
const dateMatch = header.match(/;\s+(.+)$/);
const protoMatch = header.match(/with\s+(\w+)/);
const idMatch = header.match(/id\s+([^\s;]+)/);
if (fromMatch || byMatch) {
path.push({
from: fromMatch?.[1] || 'unknown',
by: byMatch?.[1] || 'unknown',
timestamp: dateMatch?.[1],
protocol: protoMatch?.[1],
id: idMatch?.[1]
});
}
}
return path;
}
/**
* Format bytes to human readable size
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
/**
* Get security status color and icon based on result
*/
export function getSecurityStatus(result?: string): {
color: string;
icon: 'check' | 'x' | 'alert' | 'minus';
bgColor: string;
borderColor: string;
} {
switch (result) {
case 'pass':
return {
color: 'text-green-700 dark:text-green-400',
icon: 'check',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-green-600 dark:border-green-500'
};
case 'fail':
case 'permerror':
return {
color: 'text-red-700 dark:text-red-400',
icon: 'x',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-red-600 dark:border-red-500'
};
case 'softfail':
case 'neutral':
case 'temperror':
return {
color: 'text-amber-700 dark:text-amber-400',
icon: 'alert',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-amber-600 dark:border-amber-500'
};
default:
return {
color: 'text-gray-700 dark:text-gray-400',
icon: 'minus',
bgColor: 'bg-gray-50 dark:bg-gray-800',
borderColor: 'border-l-4 border-gray-400 dark:border-gray-600'
};
}
}
/**
* Parse X-Spam-LLM header to extract AI verdict and explanation
*/
export function parseSpamLLM(header: string): { verdict: string; explanation: string } | null {
// Format: "LEGITIMATE (explanation)" or "SPAM (explanation)"
// Trim the header first to remove any leading/trailing whitespace
const trimmed = header.trim();
const match = trimmed.match(/^(LEGITIMATE|SPAM|SUSPICIOUS)\s*\((.+)\)\s*$/i);
if (match) {
return {
verdict: match[1].toUpperCase(),
explanation: match[2].trim()
};
}
return null;
}
/**
* Extract list headers (List-Unsubscribe, List-Id, etc.)
*/
interface ListHeaders {
listId?: string;
listUnsubscribe?: string;
listHelp?: string;
listPost?: string;
}
export function extractListHeaders(headers: Record<string, string | string[]>): ListHeaders {
const result: ListHeaders = {};
if (headers['List-Id']) {
result.listId = Array.isArray(headers['List-Id'])
? headers['List-Id'][0]
: headers['List-Id'];
}
if (headers['List-Unsubscribe']) {
const unsub = Array.isArray(headers['List-Unsubscribe'])
? headers['List-Unsubscribe'][0]
: headers['List-Unsubscribe'];
// Extract URL from <url> format
const match = unsub.match(/<([^>]+)>/);
result.listUnsubscribe = match ? match[1] : unsub;
}
if (headers['List-Help']) {
result.listHelp = Array.isArray(headers['List-Help'])
? headers['List-Help'][0]
: headers['List-Help'];
}
if (headers['List-Post']) {
result.listPost = Array.isArray(headers['List-Post'])
? headers['List-Post'][0]
: headers['List-Post'];
}
return result;
}
+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);
// }
}
+1437
View File
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
export interface EmailHeader {
name: string;
value: string;
}
export interface Email {
id: string;
threadId: string;
mailboxIds: Record<string, boolean>;
keywords: Record<string, boolean>;
size: number;
receivedAt: string;
from?: EmailAddress[];
to?: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
replyTo?: EmailAddress[];
subject?: string;
sentAt?: string;
preview?: string;
textBody?: EmailBodyPart[];
htmlBody?: EmailBodyPart[];
bodyValues?: Record<string, EmailBodyValue>;
attachments?: Attachment[];
hasAttachment: boolean;
// Extended header information
messageId?: string;
inReplyTo?: string[];
references?: string[];
headers?: Record<string, string | string[]>;
// Security headers parsed
authenticationResults?: AuthenticationResults;
spamScore?: number;
spamStatus?: string;
spamLLM?: {
verdict: string;
explanation: string;
};
}
export interface AuthenticationResults {
spf?: {
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string;
ip?: string;
};
dkim?: {
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
domain?: string;
selector?: string;
};
dmarc?: {
result: 'pass' | 'fail' | 'none';
policy?: 'reject' | 'quarantine' | 'none';
domain?: string;
};
iprev?: {
result: 'pass' | 'fail';
ip?: string;
};
}
export interface EmailBodyValue {
value: string;
isEncodingProblem?: boolean;
isTruncated?: boolean;
}
export interface EmailAddress {
name?: string;
email: string;
}
export interface EmailBodyPart {
partId: string;
blobId: string;
size: number;
name?: string;
type: string;
charset?: string;
disposition?: string;
cid?: string;
language?: string[];
location?: string;
subParts?: EmailBodyPart[];
}
export interface Attachment {
partId: string;
blobId: string;
size: number;
name?: string;
type: string;
charset?: string;
cid?: string;
disposition?: string;
}
export interface Mailbox {
id: string;
originalId?: string; // Original JMAP ID (for shared mailboxes)
name: string;
parentId?: string;
role?: string;
sortOrder: number;
totalEmails: number;
unreadEmails: number;
totalThreads: number;
unreadThreads: number;
myRights: {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
};
isSubscribed: boolean;
// Shared folder support
accountId?: string;
accountName?: string;
isShared?: boolean;
}
export interface Thread {
id: string;
emailIds: string[];
}
// Thread grouping for UI display
export interface ThreadGroup {
threadId: string;
emails: Email[]; // Emails in this thread (sorted by receivedAt desc)
latestEmail: Email; // Most recent email
participantNames: string[];// Unique participant names
hasUnread: boolean; // Any unread emails in thread
hasStarred: boolean; // Any starred emails in thread
hasAttachment: boolean; // Any email has attachment
emailCount: number; // Total emails in thread
}
export interface Identity {
id: string;
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
mayDelete: boolean;
}
export interface EmailSubmission {
id: string;
identityId: string;
emailId: string;
threadId?: string;
envelope: {
mailFrom: EmailAddress;
rcptTo: EmailAddress[];
};
sendAt?: string;
undoStatus: "pending" | "final" | "canceled";
deliveryStatus?: Record<string, DeliveryStatus>;
dsnBlobIds?: string[];
mdnBlobIds?: string[];
}
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;
};
}
+163
View File
@@ -0,0 +1,163 @@
import type { Email, ThreadGroup } from "./jmap/types";
/**
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
* Single-email threads are still returned as ThreadGroups with emailCount=1.
*/
export function groupEmailsByThread(emails: Email[]): ThreadGroup[] {
if (!emails || emails.length === 0) {
return [];
}
// Group emails by threadId
const threadMap = new Map<string, Email[]>();
for (const email of emails) {
const threadId = email.threadId;
if (!threadMap.has(threadId)) {
threadMap.set(threadId, []);
}
threadMap.get(threadId)!.push(email);
}
// Convert to ThreadGroup array
const threadGroups: ThreadGroup[] = [];
for (const [threadId, threadEmails] of threadMap) {
// Sort emails by receivedAt descending (newest first)
const sortedEmails = [...threadEmails].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const latestEmail = sortedEmails[0];
// Collect unique participant names from all emails in thread
const participantNames = getThreadParticipants(sortedEmails);
// Check for unread, starred, and attachments
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
threadGroups.push({
threadId,
emails: sortedEmails,
latestEmail,
participantNames,
hasUnread,
hasStarred,
hasAttachment,
emailCount: sortedEmails.length,
});
}
return threadGroups;
}
/**
* Sorts thread groups by their latest email's receivedAt date (newest first).
*/
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
return [...groups].sort(
(a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
);
}
/**
* Extracts unique participant names from a list of emails.
* Includes both senders and recipients, limited to avoid UI overflow.
*/
export function getThreadParticipants(emails: Email[], maxNames: number = 4): string[] {
const seen = new Set<string>();
const names: string[] = [];
for (const email of emails) {
// Add sender
if (email.from && email.from.length > 0) {
const sender = email.from[0];
const senderName = sender.name || sender.email.split('@')[0];
const key = sender.email.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
names.push(senderName);
}
}
// Stop if we have enough names
if (names.length >= maxNames) break;
}
return names;
}
/**
* Merges newly fetched thread emails into an existing thread group.
* Used when expanding a thread to show all emails (some may not have been in the original list).
*/
export function mergeThreadEmails(
existingGroup: ThreadGroup,
fetchedEmails: Email[]
): ThreadGroup {
// Create a map of existing emails by ID
const emailMap = new Map<string, Email>();
for (const email of existingGroup.emails) {
emailMap.set(email.id, email);
}
// Add fetched emails that aren't already in the group
for (const email of fetchedEmails) {
if (!emailMap.has(email.id)) {
emailMap.set(email.id, email);
}
}
// Convert back to array and sort
const mergedEmails = Array.from(emailMap.values()).sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const latestEmail = mergedEmails[0];
const participantNames = getThreadParticipants(mergedEmails);
const hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
return {
threadId: existingGroup.threadId,
emails: mergedEmails,
latestEmail,
participantNames,
hasUnread,
hasStarred,
hasAttachment,
emailCount: mergedEmails.length,
};
}
/**
* Gets color tag from email keywords (if any).
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
return key.replace("$color:", "");
}
}
return null;
}
/**
* Checks if a thread has any color tag (returns first found).
*/
export function getThreadColorTag(emails: Email[]): string | null {
for (const email of emails) {
const color = getEmailColorTag(email.keywords);
if (color) return color;
}
return null;
}
+297
View File
@@ -0,0 +1,297 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Mailbox } from "./jmap/types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + "...";
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Types for mailbox tree
export interface MailboxNode extends Mailbox {
children: MailboxNode[];
depth: number;
}
// Role priority for mailbox ordering (lower number = higher priority)
const ROLE_PRIORITY: Record<string, number> = {
inbox: 0,
drafts: 1,
sent: 2,
archive: 3,
junk: 4,
spam: 4, // Treat spam same as junk
trash: 5,
};
// Deduplicate mailboxes (e.g., "Sent" vs "Sent Mail")
function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
const roleMap = new Map<string, Mailbox>();
const result: Mailbox[] = [];
// First pass: collect mailboxes with roles
mailboxes.forEach(mb => {
if (mb.role) {
roleMap.set(mb.role, mb);
}
});
// Second pass: filter out duplicates
mailboxes.forEach(mb => {
// If this mailbox has a role, always keep it
if (mb.role) {
result.push(mb);
return;
}
// Check if this is a duplicate of a role-based mailbox
const lowerName = mb.name.toLowerCase();
const isDuplicate = Array.from(roleMap.values()).some(roleMb => {
const roleLowerName = roleMb.name.toLowerCase();
// Check for common duplicates: "Sent Mail" vs "Sent", etc.
return lowerName.includes(roleLowerName) || roleLowerName.includes(lowerName);
});
// Only keep if not a duplicate
if (!isDuplicate) {
result.push(mb);
}
});
return result;
}
// Build a hierarchical tree structure from flat mailbox array
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
// Deduplicate mailboxes first
const deduplicated = deduplicateMailboxes(mailboxes);
// Separate own and shared mailboxes
const ownMailboxes = deduplicated.filter(mb => !mb.isShared);
const sharedMailboxes = deduplicated.filter(mb => mb.isShared);
const mailboxMap = new Map<string, MailboxNode>();
const rootMailboxes: MailboxNode[] = [];
// First pass: create nodes for own mailboxes
ownMailboxes.forEach(mailbox => {
mailboxMap.set(mailbox.id, {
...mailbox,
children: [],
depth: 0
});
});
// Second pass: build tree structure for own mailboxes
ownMailboxes.forEach(mailbox => {
const node = mailboxMap.get(mailbox.id)!;
if (mailbox.parentId && mailboxMap.has(mailbox.parentId)) {
const parent = mailboxMap.get(mailbox.parentId)!;
parent.children.push(node);
node.depth = parent.depth + 1;
} else {
// Root level mailbox or orphaned mailbox
rootMailboxes.push(node);
node.depth = 0;
}
});
// If we have shared mailboxes, create a virtual "Shared Folders" parent
if (sharedMailboxes.length > 0) {
// Group shared mailboxes by account
const accountGroups = new Map<string, Mailbox[]>();
sharedMailboxes.forEach(mb => {
const accountId = mb.accountId || 'unknown';
if (!accountGroups.has(accountId)) {
accountGroups.set(accountId, []);
}
accountGroups.get(accountId)!.push(mb);
});
// Create virtual nodes for each shared account
const sharedAccountNodes: MailboxNode[] = [];
accountGroups.forEach((accountMailboxes, accountId) => {
// Create account nodes
const accountMailboxMap = new Map<string, MailboxNode>();
const accountRootNodes: MailboxNode[] = [];
// Create nodes for this account's mailboxes
accountMailboxes.forEach(mailbox => {
accountMailboxMap.set(mailbox.id, {
...mailbox,
children: [],
depth: 2 // Account level is depth 1, these are depth 2
});
});
// Build tree for this account's mailboxes
accountMailboxes.forEach(mailbox => {
const node = accountMailboxMap.get(mailbox.id)!;
if (mailbox.parentId && accountMailboxMap.has(mailbox.parentId)) {
const parent = accountMailboxMap.get(mailbox.parentId)!;
parent.children.push(node);
node.depth = parent.depth + 1;
} else {
accountRootNodes.push(node);
}
});
// Create virtual account folder node
const accountName = accountMailboxes[0]?.accountName || accountId;
const accountNode: MailboxNode = {
id: `shared-account-${accountId}`,
name: accountName,
sortOrder: 1000, // After all own folders
totalEmails: accountMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
unreadEmails: accountMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
accountId: accountId,
accountName: accountName,
isShared: true,
children: accountRootNodes,
depth: 1,
};
sharedAccountNodes.push(accountNode);
});
// Create virtual "Shared Folders" root node
const sharedFoldersNode: MailboxNode = {
id: 'shared-folders-root',
name: 'Shared Folders',
sortOrder: 999, // After all own folders
totalEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.totalEmails, 0),
unreadEmails: sharedMailboxes.reduce((sum, mb) => sum + mb.unreadEmails, 0),
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
},
isSubscribed: true,
isShared: true,
children: sharedAccountNodes,
depth: 0,
};
rootMailboxes.push(sharedFoldersNode);
}
// Smart multi-level sorting
const sortNodes = (nodes: MailboxNode[]) => {
nodes.sort((a, b) => {
// 1. Priority: Own folders before shared folders
if (a.isShared !== b.isShared) {
return a.isShared ? 1 : -1;
}
// 2. Priority: Role-based ordering (inbox first, trash last, etc.)
const aPriority = a.role ? (ROLE_PRIORITY[a.role] ?? 999) : 999;
const bPriority = b.role ? (ROLE_PRIORITY[b.role] ?? 999) : 999;
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
// 3. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending
const aIsYear = /^\d{4}$/.test(a.name);
const bIsYear = /^\d{4}$/.test(b.name);
if (aIsYear && bIsYear) {
return parseInt(b.name) - parseInt(a.name); // Descending: 2025, 2024, 2023...
}
// 4. Fallback: Server sortOrder
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
// 5. Fallback: Alphabetical by name
return a.name.localeCompare(b.name);
});
// Recursively sort children
nodes.forEach(node => {
if (node.children.length > 0) {
sortNodes(node.children);
}
});
};
sortNodes(rootMailboxes);
return rootMailboxes;
}
// Flatten a mailbox tree for rendering with proper depth info
export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
const result: MailboxNode[] = [];
const traverse = (nodes: MailboxNode[], depth: number = 0) => {
nodes.forEach(node => {
result.push({ ...node, depth });
if (node.children.length > 0) {
traverse(node.children, depth + 1);
}
});
};
traverse(nodes);
return result;
}
+494
View File
@@ -0,0 +1,494 @@
{
"login": {
"title": "Webmail",
"username_label": "Email",
"username_placeholder": "user@example.com",
"password_label": "Password",
"password_placeholder": "Enter your password",
"sign_in": "Sign in",
"signing_in": "Signing in...",
"error": {
"invalid_credentials": "Invalid email or password",
"connection_failed": "Failed to connect to the server",
"generic": "An error occurred. Please try again."
}
},
"sidebar": {
"compose": "Compose",
"search_placeholder": "Search mail...",
"storage": "Storage",
"sign_out": "Sign out",
"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",
"system": "System theme"
},
"mailboxes": {
"inbox": "Inbox",
"sent": "Sent",
"drafts": "Drafts",
"trash": "Trash",
"archive": "Archive",
"starred": "Starred",
"all_mail": "All Mail",
"spam": "Spam",
"important": "Important"
},
"expand": "Expand",
"collapse": "Collapse"
},
"email_list": {
"no_emails": "No emails",
"no_emails_description": "Start by composing a new email",
"loading": "Loading emails...",
"unread": "unread",
"to_me": "To me",
"to_recipients": "To {{count}} recipients",
"and_others": "and {{count}} others",
"draft": "Draft",
"starred": "Starred"
},
"email_viewer": {
"no_email_selected": "No email selected",
"no_email_description": "Select an email from the list to view it here",
"no_subject": "(No Subject)",
"reply": "Reply",
"reply_all": "Reply All",
"forward": "Forward",
"delete": "Delete",
"archive": "Archive",
"star": "Star",
"unstar": "Unstar",
"mark_unread": "Mark as unread",
"mark_read": "Mark as read",
"print": "Print",
"view_source": "View source",
"email_source": "Email Source",
"copy_source": "Copy to clipboard",
"source_copied": "Source copied to clipboard",
"attachments": "Attachments",
"download": "Download",
"from": "From",
"to": "To",
"cc": "CC",
"bcc": "BCC",
"date": "Date",
"subject": "Subject",
"show_details": "Show details",
"hide_details": "Hide details",
"external_content_warning": "Images and external content have been blocked",
"load_external_content": "Load images",
"message_details": "Message Details",
"authentication": {
"title": "Authentication",
"status": {
"verified": "Verified",
"warning": "Warning",
"none": "Not authenticated"
},
"spf": {
"pass": "SPF Pass",
"fail": "SPF Fail",
"none": "No SPF"
},
"dkim": {
"pass": "DKIM Valid",
"fail": "DKIM Invalid",
"none": "No DKIM"
},
"dmarc": {
"pass": "DMARC Pass",
"fail": "DMARC Fail",
"none": "No DMARC"
},
"spam_score": "Spam Score"
},
"headers": {
"routing": "Routing",
"received": "Received",
"message_id": "Message ID",
"list_info": "List Information"
},
"color_tag": {
"title": "Color Tag",
"red": "Red",
"orange": "Orange",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue",
"purple": "Purple",
"none": "None"
}
},
"email_composer": {
"new_message": "New Message",
"reply": "Reply",
"reply_all": "Reply All",
"forward": "Forward",
"reply_to": "Reply",
"reply_all_to": "Reply All",
"forward_message": "Forward",
"to": "To",
"cc": "CC",
"bcc": "BCC",
"subject": "Subject",
"body_placeholder": "Write your message...",
"send": "Send",
"cancel": "Cancel",
"attach": "Attach files",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"quote": {
"reply_header": "On {{date}}, {{sender}} wrote:",
"forward_header": "---------- Forwarded message ----------",
"from": "From: {{sender}}",
"date": "Date: {{date}}",
"subject": "Subject: {{subject}}",
"to": "To: {{recipients}}"
}
},
"common": {
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"search": "Search",
"refresh": "Refresh",
"settings": "Settings",
"help": "Help",
"logout": "Logout",
"yes": "Yes",
"no": "No",
"unknown": "Unknown"
},
"notifications": {
"email_sent": "Email sent successfully",
"email_deleted": "Email deleted",
"email_archived": "Email archived",
"email_starred": "Email starred",
"email_unstarred": "Email unstarred",
"email_marked_read": "Email marked as read",
"email_marked_unread": "Email marked as unread",
"copied_to_clipboard": "Copied to clipboard",
"source_copied": "Source copied to clipboard",
"error_sending": "Failed to send email",
"error_deleting": "Failed to delete email",
"error_loading": "Failed to load emails",
"new_email": "New email",
"new_email_from": "From {sender}",
"click_to_view": "Click to view"
},
"date": {
"today": "Today",
"yesterday": "Yesterday",
"this_week": "This week",
"last_week": "Last week",
"this_month": "This month",
"older": "Older",
"just_now": "Just now",
"minutes_ago": "{{count}} minute ago",
"minutes_ago_plural": "{{count}} minutes ago",
"hours_ago": "{{count}} hour ago",
"hours_ago_plural": "{{count}} hours ago",
"days_ago": "{{count}} day ago",
"days_ago_plural": "{{count}} days ago"
},
"language": {
"title": "Language",
"english": "English",
"french": "Français"
},
"settings": {
"title": "Settings",
"back_to_mail": "Back to Mail",
"save_success": "Settings saved successfully",
"import_success": "Settings imported successfully",
"import_error": "Failed to import settings",
"reset_confirm": "Are you sure you want to reset all settings to defaults?",
"tabs": {
"appearance": "Appearance",
"language": "Language & Region",
"email": "Email Behavior",
"composer": "Composer",
"privacy": "Privacy & Security",
"account": "Account",
"advanced": "Advanced"
},
"appearance": {
"title": "Appearance",
"description": "Customize the look and feel of your webmail",
"theme": {
"label": "Theme",
"description": "Choose your preferred color scheme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"font_size": {
"label": "Font Size",
"description": "Adjust text size for better readability",
"small": "Small",
"medium": "Medium",
"large": "Large"
},
"list_density": {
"label": "List Density",
"description": "Control spacing in email lists",
"compact": "Compact",
"regular": "Regular",
"comfortable": "Comfortable"
},
"animations": {
"label": "Enable Animations",
"description": "Show smooth transitions and effects"
}
},
"language_region": {
"title": "Language & Region",
"description": "Configure language and regional preferences",
"language": {
"label": "Language",
"description": "Choose your preferred language",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Date Format",
"description": "How dates should be displayed",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Custom"
},
"time_format": {
"label": "Time Format",
"description": "Choose between 12-hour or 24-hour clock",
"12h": "12-hour",
"24h": "24-hour"
},
"first_day": {
"label": "First Day of Week",
"description": "Start week on Sunday or Monday",
"sunday": "Sunday",
"monday": "Monday"
}
},
"email_behavior": {
"title": "Email Behavior",
"description": "Configure how emails are handled",
"mark_read": {
"label": "Mark as Read",
"description": "When to mark emails as read when opened",
"instant": "Instantly",
"delay_3s": "After 3 seconds",
"delay_5s": "After 5 seconds",
"never": "Never"
},
"delete_action": {
"label": "Delete Action",
"description": "What happens when you delete an email",
"trash": "Move to Trash",
"permanent": "Delete Permanently"
},
"show_preview": {
"label": "Show Preview Text",
"description": "Display email preview in the list"
},
"emails_per_page": {
"label": "Emails Per Page",
"description": "Number of emails to load at once",
"25": "25 emails",
"50": "50 emails",
"100": "100 emails"
},
"external_content": {
"label": "External Content",
"description": "How to handle images and external content",
"ask": "Always ask",
"block": "Always block",
"allow": "Always allow"
}
},
"composer": {
"title": "Composer",
"description": "Configure email composition settings",
"autosave": {
"label": "Auto-save Interval",
"description": "How often to save drafts automatically",
"30s": "Every 30 seconds",
"1m": "Every minute",
"2m": "Every 2 minutes",
"5m": "Every 5 minutes"
},
"send_confirmation": {
"label": "Send Confirmation",
"description": "Ask for confirmation before sending emails"
},
"default_reply": {
"label": "Default Reply Mode",
"description": "Default action when clicking reply",
"reply": "Reply",
"reply_all": "Reply All"
}
},
"privacy": {
"title": "Privacy & Security",
"description": "Manage your privacy and security settings",
"external_images": {
"label": "Block External Images",
"description": "Prevent tracking through external images"
},
"session_timeout": {
"label": "Session Timeout",
"description": "Automatically log out after inactivity",
"never": "Never",
"30m": "30 minutes",
"1h": "1 hour",
"4h": "4 hours"
},
"clear_cache": {
"label": "Clear Cache",
"description": "Remove cached data and temporary files",
"button": "Clear Cache",
"confirm": "Are you sure you want to clear the cache?",
"success": "Cache cleared successfully"
}
},
"account": {
"title": "Account",
"description": "View your account information",
"email": {
"label": "Email Address",
"value": "{{email}}"
},
"server": {
"label": "JMAP Server",
"value": "{{server}}"
},
"storage": {
"label": "Storage Usage",
"used": "{{used}} of {{total}} used",
"percentage": "{{percent}}% used"
},
"last_sync": {
"label": "Last Sync",
"value": "{{time}}"
}
},
"advanced": {
"title": "Advanced",
"description": "Advanced options and developer settings",
"debug_mode": {
"label": "Debug Mode",
"description": "Enable detailed logging for troubleshooting"
},
"keyboard_shortcuts": {
"label": "Keyboard Shortcuts",
"description": "View available keyboard shortcuts",
"button": "View Shortcuts"
},
"reset_settings": {
"label": "Reset Settings",
"description": "Restore all settings to default values",
"button": "Reset to Defaults"
},
"export_settings": {
"label": "Export Settings",
"description": "Download your settings as JSON",
"button": "Export"
},
"import_settings": {
"label": "Import Settings",
"description": "Upload settings from JSON file",
"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"
},
"context_menu": {
"reply": "Reply",
"reply_all": "Reply All",
"forward": "Forward",
"mark_read": "Mark as Read",
"mark_unread": "Mark as Unread",
"star": "Star",
"unstar": "Unstar",
"move_to": "Move to...",
"archive": "Archive",
"delete": "Delete",
"color_tag": "Color Tag",
"remove_color": "Remove Color",
"items_selected": "{{count}} emails selected"
},
"shortcuts": {
"title": "Keyboard Shortcuts",
"tip": "Press ? anytime to show this help",
"sections": {
"navigation": "Navigation",
"actions": "Email Actions",
"global": "Global",
"threads": "Threads"
},
"navigation": {
"next_email": "Next email",
"previous_email": "Previous email",
"open_email": "Open email",
"close_email": "Close / Deselect"
},
"actions": {
"reply": "Reply",
"reply_all": "Reply all",
"forward": "Forward",
"star": "Toggle star",
"archive": "Archive",
"delete": "Delete",
"mark_unread": "Mark as unread",
"mark_read": "Mark as read"
},
"global": {
"compose": "Compose new email",
"search": "Focus search",
"help": "Show shortcuts",
"refresh": "Refresh emails",
"select_all": "Select all"
},
"threads": {
"expand_collapse": "Expand/collapse thread"
}
},
"threads": {
"messages_one": "{count} message",
"messages_other": "{count} messages",
"expand": "Expand conversation",
"collapse": "Collapse conversation",
"loading": "Loading conversation...",
"mark_read": "Mark conversation as read",
"mark_unread": "Mark conversation as unread",
"archive": "Archive conversation",
"delete": "Delete conversation",
"star": "Star conversation",
"unstar": "Unstar conversation"
}
}
+494
View File
@@ -0,0 +1,494 @@
{
"login": {
"title": "Webmail",
"username_label": "Email",
"username_placeholder": "utilisateur@exemple.com",
"password_label": "Mot de passe",
"password_placeholder": "Entrez votre mot de passe",
"sign_in": "Se connecter",
"signing_in": "Connexion en cours...",
"error": {
"invalid_credentials": "Email ou mot de passe invalide",
"connection_failed": "Échec de la connexion au serveur",
"generic": "Une erreur s'est produite. Veuillez réessayer."
}
},
"sidebar": {
"compose": "Composer",
"search_placeholder": "Rechercher un email...",
"storage": "Stockage",
"sign_out": "Se déconnecter",
"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",
"system": "Thème système"
},
"mailboxes": {
"inbox": "Boîte de réception",
"sent": "Envoyés",
"drafts": "Brouillons",
"trash": "Corbeille",
"archive": "Archives",
"starred": "Favoris",
"all_mail": "Tous les messages",
"spam": "Spam",
"important": "Important"
},
"expand": "Développer",
"collapse": "Réduire"
},
"email_list": {
"no_emails": "Aucun email",
"no_emails_description": "Commencez par composer un nouvel email",
"loading": "Chargement des emails...",
"unread": "non lu",
"to_me": "À moi",
"to_recipients": "À {{count}} destinataires",
"and_others": "et {{count}} autres",
"draft": "Brouillon",
"starred": "Favori"
},
"email_viewer": {
"no_email_selected": "Aucun email sélectionné",
"no_email_description": "Sélectionnez un email dans la liste pour le voir ici",
"no_subject": "(Sans objet)",
"reply": "Répondre",
"reply_all": "Répondre à tous",
"forward": "Transférer",
"delete": "Supprimer",
"archive": "Archiver",
"star": "Marquer comme favori",
"unstar": "Retirer des favoris",
"mark_unread": "Marquer comme non lu",
"mark_read": "Marquer comme lu",
"print": "Imprimer",
"view_source": "Voir la source",
"email_source": "Source de l'email",
"copy_source": "Copier dans le presse-papiers",
"source_copied": "Source copiée dans le presse-papiers",
"attachments": "Pièces jointes",
"download": "Télécharger",
"from": "De",
"to": "À",
"cc": "CC",
"bcc": "CCI",
"date": "Date",
"subject": "Objet",
"show_details": "Afficher les détails",
"hide_details": "Masquer les détails",
"external_content_warning": "Les images et le contenu externe ont été bloqués",
"load_external_content": "Charger les images",
"message_details": "Détails du message",
"authentication": {
"title": "Authentification",
"status": {
"verified": "Vérifié",
"warning": "Attention",
"none": "Non authentifié"
},
"spf": {
"pass": "SPF Validé",
"fail": "SPF Échoué",
"none": "Pas de SPF"
},
"dkim": {
"pass": "DKIM Valide",
"fail": "DKIM Invalide",
"none": "Pas de DKIM"
},
"dmarc": {
"pass": "DMARC Validé",
"fail": "DMARC Échoué",
"none": "Pas de DMARC"
},
"spam_score": "Score de spam"
},
"headers": {
"routing": "Routage",
"received": "Reçu",
"message_id": "ID du message",
"list_info": "Information de liste"
},
"color_tag": {
"title": "Étiquette de couleur",
"red": "Rouge",
"orange": "Orange",
"yellow": "Jaune",
"green": "Vert",
"blue": "Bleu",
"purple": "Violet",
"none": "Aucune"
}
},
"email_composer": {
"new_message": "Nouveau message",
"reply": "Répondre",
"reply_all": "Répondre à tous",
"forward": "Transférer",
"reply_to": "Répondre",
"reply_all_to": "Répondre à tous",
"forward_message": "Transférer",
"to": "À",
"cc": "CC",
"bcc": "CCI",
"subject": "Objet",
"body_placeholder": "Écrivez votre message...",
"send": "Envoyer",
"cancel": "Annuler",
"attach": "Joindre des fichiers",
"discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?",
"quote": {
"reply_header": "Le {{date}}, {{sender}} a écrit :",
"forward_header": "---------- Message transféré ----------",
"from": "De : {{sender}}",
"date": "Date : {{date}}",
"subject": "Objet : {{subject}}",
"to": "À : {{recipients}}"
}
},
"common": {
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès",
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"edit": "Modifier",
"close": "Fermer",
"search": "Rechercher",
"refresh": "Actualiser",
"settings": "Paramètres",
"help": "Aide",
"logout": "Déconnexion",
"yes": "Oui",
"no": "Non",
"unknown": "Inconnu"
},
"notifications": {
"email_sent": "Email envoyé avec succès",
"email_deleted": "Email supprimé",
"email_archived": "Email archivé",
"email_starred": "Email ajouté aux favoris",
"email_unstarred": "Email retiré des favoris",
"email_marked_read": "Email marqué comme lu",
"email_marked_unread": "Email marqué comme non lu",
"copied_to_clipboard": "Copié dans le presse-papiers",
"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",
"new_email": "Nouvel email",
"new_email_from": "De {sender}",
"click_to_view": "Cliquer pour voir"
},
"date": {
"today": "Aujourd'hui",
"yesterday": "Hier",
"this_week": "Cette semaine",
"last_week": "La semaine dernière",
"this_month": "Ce mois-ci",
"older": "Plus ancien",
"just_now": "À l'instant",
"minutes_ago": "Il y a {{count}} minute",
"minutes_ago_plural": "Il y a {{count}} minutes",
"hours_ago": "Il y a {{count}} heure",
"hours_ago_plural": "Il y a {{count}} heures",
"days_ago": "Il y a {{count}} jour",
"days_ago_plural": "Il y a {{count}} jours"
},
"language": {
"title": "Langue",
"english": "English",
"french": "Français"
},
"settings": {
"title": "Paramètres",
"back_to_mail": "Retour aux emails",
"save_success": "Paramètres enregistrés avec succès",
"import_success": "Paramètres importés avec succès",
"import_error": "Échec de l'importation des paramètres",
"reset_confirm": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres ?",
"tabs": {
"appearance": "Apparence",
"language": "Langue et région",
"email": "Comportement email",
"composer": "Compositeur",
"privacy": "Confidentialité et sécurité",
"account": "Compte",
"advanced": "Avancé"
},
"appearance": {
"title": "Apparence",
"description": "Personnalisez l'apparence de votre webmail",
"theme": {
"label": "Thème",
"description": "Choisissez votre schéma de couleurs préféré",
"light": "Clair",
"dark": "Sombre",
"system": "Système"
},
"font_size": {
"label": "Taille de police",
"description": "Ajustez la taille du texte pour une meilleure lisibilité",
"small": "Petite",
"medium": "Moyenne",
"large": "Grande"
},
"list_density": {
"label": "Densité de la liste",
"description": "Contrôlez l'espacement dans les listes d'emails",
"compact": "Compacte",
"regular": "Normale",
"comfortable": "Confortable"
},
"animations": {
"label": "Activer les animations",
"description": "Afficher les transitions et effets fluides"
}
},
"language_region": {
"title": "Langue et région",
"description": "Configurez vos préférences linguistiques et régionales",
"language": {
"label": "Langue",
"description": "Choisissez votre langue préférée",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Format de date",
"description": "Comment les dates doivent être affichées",
"regional": "Régional",
"iso": "ISO 8601",
"custom": "Personnalisé"
},
"time_format": {
"label": "Format d'heure",
"description": "Choisissez entre 12 heures ou 24 heures",
"12h": "12 heures",
"24h": "24 heures"
},
"first_day": {
"label": "Premier jour de la semaine",
"description": "Commencer la semaine le dimanche ou le lundi",
"sunday": "Dimanche",
"monday": "Lundi"
}
},
"email_behavior": {
"title": "Comportement email",
"description": "Configurez la gestion des emails",
"mark_read": {
"label": "Marquer comme lu",
"description": "Quand marquer les emails comme lus à l'ouverture",
"instant": "Instantanément",
"delay_3s": "Après 3 secondes",
"delay_5s": "Après 5 secondes",
"never": "Jamais"
},
"delete_action": {
"label": "Action de suppression",
"description": "Que se passe-t-il quand vous supprimez un email",
"trash": "Déplacer vers la corbeille",
"permanent": "Supprimer définitivement"
},
"show_preview": {
"label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste"
},
"emails_per_page": {
"label": "Emails par page",
"description": "Nombre d'emails à charger à la fois",
"25": "25 emails",
"50": "50 emails",
"100": "100 emails"
},
"external_content": {
"label": "Contenu externe",
"description": "Comment gérer les images et le contenu externe",
"ask": "Toujours demander",
"block": "Toujours bloquer",
"allow": "Toujours autoriser"
}
},
"composer": {
"title": "Compositeur",
"description": "Configurez les paramètres de composition d'email",
"autosave": {
"label": "Intervalle de sauvegarde automatique",
"description": "Fréquence de sauvegarde automatique des brouillons",
"30s": "Toutes les 30 secondes",
"1m": "Toutes les minutes",
"2m": "Toutes les 2 minutes",
"5m": "Toutes les 5 minutes"
},
"send_confirmation": {
"label": "Confirmation d'envoi",
"description": "Demander une confirmation avant d'envoyer les emails"
},
"default_reply": {
"label": "Mode de réponse par défaut",
"description": "Action par défaut lors du clic sur répondre",
"reply": "Répondre",
"reply_all": "Répondre à tous"
}
},
"privacy": {
"title": "Confidentialité et sécurité",
"description": "Gérez vos paramètres de confidentialité et sécurité",
"external_images": {
"label": "Bloquer les images externes",
"description": "Empêcher le suivi par les images externes"
},
"session_timeout": {
"label": "Délai d'expiration de session",
"description": "Déconnexion automatique après inactivité",
"never": "Jamais",
"30m": "30 minutes",
"1h": "1 heure",
"4h": "4 heures"
},
"clear_cache": {
"label": "Vider le cache",
"description": "Supprimer les données en cache et fichiers temporaires",
"button": "Vider le cache",
"confirm": "Êtes-vous sûr de vouloir vider le cache ?",
"success": "Cache vidé avec succès"
}
},
"account": {
"title": "Compte",
"description": "Consultez les informations de votre compte",
"email": {
"label": "Adresse email",
"value": "{{email}}"
},
"server": {
"label": "Serveur JMAP",
"value": "{{server}}"
},
"storage": {
"label": "Utilisation du stockage",
"used": "{{used}} sur {{total}} utilisés",
"percentage": "{{percent}}% utilisé"
},
"last_sync": {
"label": "Dernière synchronisation",
"value": "{{time}}"
}
},
"advanced": {
"title": "Avancé",
"description": "Options avancées et paramètres développeur",
"debug_mode": {
"label": "Mode débogage",
"description": "Activer la journalisation détaillée pour le dépannage"
},
"keyboard_shortcuts": {
"label": "Raccourcis clavier",
"description": "Voir les raccourcis clavier disponibles",
"button": "Voir les raccourcis"
},
"reset_settings": {
"label": "Réinitialiser les paramètres",
"description": "Restaurer tous les paramètres par défaut",
"button": "Réinitialiser"
},
"export_settings": {
"label": "Exporter les paramètres",
"description": "Télécharger vos paramètres au format JSON",
"button": "Exporter"
},
"import_settings": {
"label": "Importer les paramètres",
"description": "Charger les paramètres depuis un fichier JSON",
"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"
},
"context_menu": {
"reply": "Répondre",
"reply_all": "Répondre à tous",
"forward": "Transférer",
"mark_read": "Marquer comme lu",
"mark_unread": "Marquer comme non lu",
"star": "Marquer comme favori",
"unstar": "Retirer des favoris",
"move_to": "Déplacer vers...",
"archive": "Archiver",
"delete": "Supprimer",
"color_tag": "Étiquette de couleur",
"remove_color": "Supprimer la couleur",
"items_selected": "{{count}} emails sélectionnés"
},
"shortcuts": {
"title": "Raccourcis clavier",
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
"sections": {
"navigation": "Navigation",
"actions": "Actions email",
"global": "Global",
"threads": "Conversations"
},
"navigation": {
"next_email": "Email suivant",
"previous_email": "Email précédent",
"open_email": "Ouvrir l'email",
"close_email": "Fermer / Désélectionner"
},
"actions": {
"reply": "Répondre",
"reply_all": "Répondre à tous",
"forward": "Transférer",
"star": "Basculer favori",
"archive": "Archiver",
"delete": "Supprimer",
"mark_unread": "Marquer comme non lu",
"mark_read": "Marquer comme lu"
},
"global": {
"compose": "Composer un email",
"search": "Rechercher",
"help": "Afficher les raccourcis",
"refresh": "Actualiser les emails",
"select_all": "Tout sélectionner"
},
"threads": {
"expand_collapse": "Développer/réduire la conversation"
}
},
"threads": {
"messages_one": "{count} message",
"messages_other": "{count} messages",
"expand": "Développer la conversation",
"collapse": "Réduire la conversation",
"loading": "Chargement de la conversation...",
"mark_read": "Marquer la conversation comme lue",
"mark_unread": "Marquer la conversation comme non lue",
"archive": "Archiver la conversation",
"delete": "Supprimer la conversation",
"star": "Marquer la conversation comme favorite",
"unstar": "Retirer des favoris"
}
}
+8
View File
@@ -0,0 +1,8 @@
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = withNextIntl(nextConfig);
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+7821
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
{
"name": "jmap-webmail",
"version": "1.0.0",
"description": "A modern JMAP webmail client built for Stalwart Mail Server",
"author": "Matthieu MALVACHE <matthieu@root.cloud>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/root-fr/jmap-webmail.git"
},
"homepage": "https://github.com/root-fr/jmap-webmail",
"bugs": {
"url": "https://github.com/root-fr/jmap-webmail/issues"
},
"keywords": [
"jmap",
"webmail",
"stalwart",
"email",
"nextjs",
"react",
"typescript"
],
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"prepare": "husky",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@types/dompurify": "^3.0.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.7",
"jmap-jam": "^0.11.0",
"lucide-react": "^0.556.0",
"next": "^16.0.8",
"next-auth": "^4.24.11",
"next-intl": "^4.5.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@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",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"tailwindcss": "^4.1.17",
"typescript": "^5"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n/request';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'always', // Always show locale in URL for consistency
localeDetection: true // Enable browser language detection
});
export const config = {
// Skip all paths that should not be internationalized
matcher: ['/((?!api|_next|.*\\..*).*)']
};
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+141
View File
@@ -0,0 +1,141 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
serverUrl: string | null;
username: string | null;
client: JMAPClient | null;
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
isAuthenticated: false,
isLoading: false,
error: null,
serverUrl: null,
username: null,
client: null,
login: async (serverUrl, username, password) => {
set({ isLoading: true, error: null });
try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, password);
// Try to connect
await client.connect();
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
isLoading: false,
serverUrl,
username,
client,
error: null,
});
return true;
} catch (error) {
console.error('Login error:', error);
let errorKey = 'generic';
// Map common errors to translation keys
if (error instanceof Error) {
if (error.message.includes('Invalid username or password') ||
error.message.includes('401') ||
error.message.includes('Unauthorized')) {
errorKey = 'invalid_credentials';
} else if (error.message.includes('network') ||
error.message.includes('Failed to fetch')) {
errorKey = 'connection_failed';
}
}
set({
isLoading: false,
error: errorKey,
isAuthenticated: false,
client: null,
});
return false;
}
},
logout: () => {
const state = get();
// Disconnect the JMAP client if it exists
if (state.client) {
state.client.disconnect();
}
set({
isAuthenticated: false,
serverUrl: null,
username: null,
client: null,
error: null,
});
// Clear persisted storage
localStorage.removeItem('auth-storage');
// Clear email store state
useEmailStore.setState({
emails: [],
mailboxes: [],
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
error: null,
searchQuery: "",
quota: null,
});
},
checkAuth: async () => {
const state = get();
// If authenticated but no client (e.g., after page refresh), we can't restore the session
// because we don't store passwords for security reasons
if (state.isAuthenticated && !state.client) {
// Reset auth state - user will need to log in again
set({
isAuthenticated: false,
isLoading: false,
client: null,
serverUrl: null,
username: null,
});
}
// Mark loading as complete
set({ isLoading: false });
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({
// Only persist non-sensitive data
serverUrl: state.serverUrl,
username: state.username,
// Don't persist isAuthenticated since we can't restore the session without a password
}),
}
)
);
+984
View File
@@ -0,0 +1,984 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
interface EmailStore {
emails: Email[];
mailboxes: Mailbox[];
selectedEmail: Email | null;
selectedMailbox: string;
isLoading: boolean;
isLoadingEmail: boolean; // Track when a full email is being fetched
isLoadingMore: boolean; // Track when loading more emails (pagination)
error: string | null;
searchQuery: string;
quota: { used: number; total: number } | null;
processingReadStatus: Set<string>; // Track emails being marked as read/unread
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
// Thread expansion state
expandedThreadIds: Set<string>; // Which threads are expanded in the list
threadEmailsCache: Map<string, Email[]>; // Cache of fully fetched thread emails
isLoadingThread: string | null; // Thread ID currently being loaded
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
selectEmail: (email: Email | null) => void;
selectMailbox: (mailboxId: string) => void;
setLoading: (loading: boolean) => void;
setLoadingEmail: (loading: boolean) => void;
setError: (error: string | null) => void;
setSearchQuery: (query: string) => void;
setQuota: (quota: { used: number; total: number } | null) => void;
toggleEmailSelection: (emailId: string) => void;
selectAllEmails: () => void;
clearSelection: () => void;
// JMAP operations
fetchMailboxes: (client: JMAPClient) => Promise<void>;
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>;
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
// Batch operations
batchMarkAsRead: (client: JMAPClient, read: boolean) => Promise<void>;
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;
// Thread expansion actions
toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
// Mock data for demo
loadMockData: () => void;
}
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
isLoadingEmail: false,
isLoadingMore: false,
error: null,
searchQuery: "",
quota: null,
processingReadStatus: new Set(),
selectedEmailIds: new Set(),
hasMoreEmails: false,
totalEmails: 0,
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
// Thread expansion state
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
selectEmail: (email) => set({ selectedEmail: email }),
selectMailbox: (mailboxId) => set({
selectedMailbox: mailboxId,
selectedEmail: null,
selectedEmailIds: new Set(),
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
}),
setLoading: (loading) => set({ isLoading: loading }),
setLoadingEmail: (loading) => set({ isLoadingEmail: loading }),
setError: (error) => set({ error }),
setSearchQuery: (query) => set({ searchQuery: query }),
setQuota: (quota) => set({ quota }),
toggleEmailSelection: (emailId) => {
const { selectedEmailIds } = get();
const newSelection = new Set(selectedEmailIds);
if (newSelection.has(emailId)) {
newSelection.delete(emailId);
} else {
newSelection.add(emailId);
}
set({ selectedEmailIds: newSelection });
},
selectAllEmails: () => {
const { emails } = get();
const allIds = new Set(emails.map(e => e.id));
set({ selectedEmailIds: allIds });
},
clearSelection: () => {
set({ selectedEmailIds: new Set() });
},
// JMAP operations
fetchMailboxes: async (client) => {
set({ isLoading: true, error: null });
try {
const mailboxes = await client.getAllMailboxes();
// Auto-select inbox if no mailbox is currently selected
const currentSelectedMailbox = get().selectedMailbox;
if (!currentSelectedMailbox) {
// Find inbox from PRIMARY account (not shared accounts)
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
if (inboxMailbox) {
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
} else {
set({ mailboxes, isLoading: false });
}
} else {
set({ mailboxes, isLoading: false });
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
isLoading: false
});
}
},
fetchEmails: async (client, mailboxId) => {
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
try {
const targetMailboxId = mailboxId || get().selectedMailbox;
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === targetMailboxId);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
});
} catch (error) {
console.error('Failed to fetch emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0
});
}
},
loadMoreEmails: async (client) => {
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
set({ isLoadingMore: true, error: null });
try {
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
set({
emails: [...emails, ...result.emails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false
});
} catch (error) {
console.error('Failed to load more emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false
});
}
},
fetchEmailContent: async (client, emailId) => {
try {
// Find the selected mailbox to determine accountId (for shared folders)
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const email = await client.getEmail(emailId, accountId);
if (email) {
set({ selectedEmail: email });
}
return email;
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch email content"
});
return null;
}
},
fetchQuota: async (client) => {
try {
const quota = await client.getQuota();
set({ quota });
} catch {
// Don't set error state as quota is optional
}
},
sendEmail: async (client, to, subject, body, cc, bcc, draftId) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, draftId);
// Refresh emails after sending
await get().fetchEmails(client);
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
isLoading: false
});
throw error;
}
},
deleteEmail: async (client, emailId) => {
try {
// Get the email to check if it's unread and which mailboxes it belongs to
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isUnread = !email.keywords?.$seen;
// Get delete action preference from settings
const deleteAction = useSettingsStore.getState().deleteAction;
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
// If deleteAction is 'trash', try to move to trash mailbox
if (deleteAction === 'trash') {
// Find trash mailbox for the correct account
const trashMailbox = mailboxes.find(mb => {
if (accountId) {
// For shared folders, match by accountId
return mb.role === 'trash' && mb.accountId === accountId;
}
// For primary account, find trash that's not from a shared folder
return mb.role === 'trash' && !mb.isShared;
});
if (trashMailbox) {
// Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id;
await client.moveToTrash(emailId, trashId, accountId);
// Remove from local state (email moved to trash, not in current view)
set((state) => {
let updatedMailboxes = state.mailboxes;
// Update counters for source mailbox (email leaving)
if (email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
};
}
// Update trash mailbox counters (email arriving)
if (mailbox.id === trashMailbox.id) {
return {
...mailbox,
totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
};
}
return mailbox;
});
}
return {
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
mailboxes: updatedMailboxes
};
});
return;
}
// If no trash mailbox found, fall through to permanent delete
}
// Permanent delete
await client.deleteEmail(emailId);
// Remove from local state and update mailbox counters if needed
set((state) => {
let updatedMailboxes = state.mailboxes;
// If the email was unread, decrement the unread counters
if (isUnread && email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: Math.max(0, mailbox.unreadEmails - 1),
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: Math.max(0, mailbox.unreadThreads - 1)
};
}
return mailbox;
});
} else if (email.mailboxIds) {
// If email was read, only decrement total counters
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
totalThreads: Math.max(0, mailbox.totalThreads - 1)
};
}
return mailbox;
});
}
return {
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
mailboxes: updatedMailboxes
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to delete email"
});
throw error;
}
},
markAsRead: async (client, emailId, read) => {
try {
// Check if this email is already being processed
const processingKey = `${emailId}-${read}`;
const currentProcessing = get().processingReadStatus;
if (currentProcessing.has(processingKey)) {
return; // Already being processed
}
// Get the email to check its current state and mailboxes
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
// Check if already in the desired state
const isCurrentlyRead = email.keywords?.$seen === true;
if (isCurrentlyRead === read) {
return; // Already in desired state
}
// Add to processing set
set((state) => ({
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
}));
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
await client.markAsRead(emailId, read, accountId);
// Update local state including mailbox counters
set((state) => {
// Remove from processing set
const newProcessingSet = new Set(state.processingReadStatus);
newProcessingSet.delete(processingKey);
// Only update counters if the state is actually changing
const emailInState = state.emails.find(e => e.id === emailId);
if (!emailInState) return { processingReadStatus: newProcessingSet };
const wasRead = emailInState.keywords?.$seen === true;
if (wasRead === read) {
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
}
const updatedMailboxes = state.mailboxes.map(mailbox => {
// Check if this email belongs to this mailbox
if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
const delta = read ? -1 : 1;
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
unreadThreads: Math.max(0, mailbox.unreadThreads + delta)
};
}
return mailbox;
});
return {
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
mailboxes: updatedMailboxes,
processingReadStatus: newProcessingSet
};
});
} catch (error) {
// Remove from processing set on error
set((state) => {
const newProcessingSet = new Set(state.processingReadStatus);
newProcessingSet.delete(`${emailId}-${read}`);
return {
processingReadStatus: newProcessingSet,
error: error instanceof Error ? error.message : "Failed to update email"
};
});
throw error;
}
},
moveToMailbox: async (client, emailId, destinationMailboxId) => {
try {
// Get the email to check its current mailboxes and read status
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isUnread = !email.keywords?.$seen;
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
await client.moveEmail(emailId, destinationMailboxId);
// Update local state and mailbox counters
set((state) => {
// Update mailbox counters
const updatedMailboxes = state.mailboxes.map(mailbox => {
// Remove from current mailboxes
if (currentMailboxIds.includes(mailbox.id)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
};
}
// Add to destination mailbox
else if (mailbox.id === destinationMailboxId) {
return {
...mailbox,
totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
};
}
return mailbox;
});
// Update the email's mailboxIds
const updatedEmails = state.emails.map(e => {
if (e.id === emailId) {
return {
...e,
mailboxIds: { [destinationMailboxId]: true }
};
}
return e;
});
// If moved email is selected, update selectedEmail too
const updatedSelectedEmail = state.selectedEmail?.id === emailId
? { ...state.selectedEmail, mailboxIds: { [destinationMailboxId]: true } }
: state.selectedEmail;
return {
emails: updatedEmails,
selectedEmail: updatedSelectedEmail,
mailboxes: updatedMailboxes
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move email"
});
throw error;
}
},
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
const emails = await client.searchEmails(query);
set({ emails, isLoading: false, hasMoreEmails: false, totalEmails: emails.length });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0
});
}
},
toggleStar: async (client, emailId) => {
try {
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isFlagged = email.keywords.$flagged || false;
await client.toggleStar(emailId, !isFlagged);
// Update local state
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } }
: state.selectedEmail
}));
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to update star"
});
throw error;
}
},
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMarkAsRead(emailIdsArray, read);
// Update local state
const updatedEmails = emails.map(email =>
selectedEmailIds.has(email.id)
? { ...email, keywords: { ...email.keywords, $seen: read } }
: email
);
// Update mailbox counters
const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaUnread = 0;
affectedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
const wasRead = email.keywords?.$seen === true;
if (wasRead !== read) {
deltaUnread += read ? -1 : 1;
}
}
});
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread)
};
});
set({
emails: updatedEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to update emails",
isLoading: false
});
}
},
batchDelete: async (client) => {
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchDeleteEmails(emailIdsArray);
// Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
// Update mailbox counters
const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
deltaTotalEmails--;
if (!email.keywords?.$seen) {
deltaUnreadEmails--;
}
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails)
};
});
set({
emails: remainingEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to delete emails",
isLoading: false
});
}
},
batchMoveToMailbox: async (client, toMailboxId) => {
const { selectedEmailIds, emails } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMoveEmails(emailIdsArray, toMailboxId);
// Update local state - remove from current view since they moved
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
set({
emails: remainingEmails,
selectedEmailIds: new Set(),
isLoading: false
});
// Refresh emails to get updated list
await get().fetchEmails(client, get().selectedMailbox);
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move emails",
isLoading: false
});
}
},
// 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 });
},
// Thread expansion actions
toggleThreadExpansion: (threadId) => {
const { expandedThreadIds } = get();
const newExpandedThreadIds = new Set(expandedThreadIds);
if (newExpandedThreadIds.has(threadId)) {
newExpandedThreadIds.delete(threadId);
} else {
newExpandedThreadIds.add(threadId);
}
set({ expandedThreadIds: newExpandedThreadIds });
},
fetchThreadEmails: async (client, threadId) => {
const { threadEmailsCache, selectedMailbox, mailboxes } = get();
// Check if we already have this thread cached
const cachedEmails = threadEmailsCache.get(threadId);
if (cachedEmails && cachedEmails.length > 0) {
return cachedEmails;
}
// Set loading state
set({ isLoadingThread: threadId });
try {
// Determine accountId for shared folders
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Fetch all emails in the thread
const emails = await client.getThreadEmails(threadId, accountId);
// Update cache
const newCache = new Map(get().threadEmailsCache);
newCache.set(threadId, emails);
set({
threadEmailsCache: newCache,
isLoadingThread: null
});
return emails;
} catch (error) {
console.error('Failed to fetch thread emails:', error);
set({ isLoadingThread: null });
return [];
}
},
collapseAllThreads: () => {
set({
expandedThreadIds: new Set(),
isLoadingThread: null
});
},
updateThreadCache: (threadId, emails) => {
const newCache = new Map(get().threadEmailsCache);
newCache.set(threadId, emails);
set({ threadEmailsCache: newCache });
},
loadMockData: () => {
const mockEmails: Email[] = [
{
id: "1",
threadId: "thread-1",
mailboxIds: { inbox: true },
keywords: { $seen: false },
size: 1024,
receivedAt: new Date().toISOString(),
from: [{ name: "Alice Johnson", email: "alice@example.com" }],
to: [{ email: "you@example.com" }],
subject: "Q4 Budget Review Meeting",
preview: "Hi team, I wanted to schedule a meeting to review our Q4 budget projections. Are you available this Thursday at 2 PM? We need to discuss...",
hasAttachment: true,
},
{
id: "2",
threadId: "thread-2",
mailboxIds: { inbox: true },
keywords: { $seen: true, $flagged: true },
size: 512,
receivedAt: new Date(Date.now() - 3600000).toISOString(),
from: [{ name: "Bob Smith", email: "bob@company.com" }],
to: [{ email: "you@example.com" }],
subject: "Re: Project Timeline Update",
preview: "Thanks for the update. The new timeline looks good to me. I've reviewed the milestones and everything seems achievable...",
hasAttachment: false,
},
{
id: "3",
threadId: "thread-3",
mailboxIds: { inbox: true },
keywords: { $seen: false },
size: 2048,
receivedAt: new Date(Date.now() - 7200000).toISOString(),
from: [{ name: "Carol White", email: "carol@design.co" }],
to: [{ email: "you@example.com" }],
subject: "New Design Mockups Ready",
preview: "Hey! The new mockups for the landing page are ready for review. I've incorporated all the feedback from last week's meeting...",
hasAttachment: true,
},
{
id: "4",
threadId: "thread-4",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 768,
receivedAt: new Date(Date.now() - 86400000).toISOString(),
from: [{ name: "GitHub", email: "notifications@github.com" }],
to: [{ email: "you@example.com" }],
subject: "[PR] Feature: Add authentication module",
preview: "A new pull request has been opened in your repository. This PR adds a comprehensive authentication module with OAuth support...",
hasAttachment: false,
},
{
id: "5",
threadId: "thread-5",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1536,
receivedAt: new Date(Date.now() - 172800000).toISOString(),
from: [{ name: "David Lee", email: "david@startup.io" }],
to: [{ email: "you@example.com" }],
subject: "Investment Proposal Discussion",
preview: "Following up on our call yesterday, I'm sending over the investment proposal we discussed. The terms are quite favorable...",
hasAttachment: true,
},
];
const mockMailboxes: Mailbox[] = [
{
id: "inbox",
name: "Inbox",
role: "inbox",
sortOrder: 1,
totalEmails: 5,
unreadEmails: 2,
totalThreads: 5,
unreadThreads: 2,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
isSubscribed: true,
},
];
set({
emails: mockEmails,
mailboxes: mockMailboxes,
});
},
}));
+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface LocaleStore {
locale: string;
setLocale: (locale: string) => void;
}
export const useLocaleStore = create<LocaleStore>()(
persist(
(set) => ({
locale: 'en',
setLocale: (locale) => set({ locale }),
}),
{
name: 'locale-storage',
}
)
);
+213
View File
@@ -0,0 +1,213 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontSize = 'small' | 'medium' | 'large';
export type ListDensity = 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll';
export type DateFormat = 'regional' | 'iso' | 'custom';
export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
interface SettingsState {
// Appearance
fontSize: FontSize;
listDensity: ListDensity;
animationsEnabled: boolean;
// Language & Region
dateFormat: DateFormat;
timeFormat: TimeFormat;
firstDayOfWeek: FirstDayOfWeek;
// Email Behavior
markAsReadDelay: number; // milliseconds (0 = instant, -1 = never)
deleteAction: DeleteAction;
showPreview: boolean;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
// Composer
autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean;
defaultReplyMode: ReplyMode;
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
// Advanced
debugMode: boolean;
// Actions
updateSetting: <K extends keyof SettingsState>(
key: K,
value: SettingsState[K]
) => void;
resetToDefaults: () => void;
exportSettings: () => string;
importSettings: (json: string) => boolean;
}
const DEFAULT_SETTINGS = {
// Appearance
fontSize: 'medium' as FontSize,
listDensity: 'regular' as ListDensity,
animationsEnabled: true,
// Language & Region
dateFormat: 'regional' as DateFormat,
timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
// Email Behavior
markAsReadDelay: 0, // Instant
deleteAction: 'trash' as DeleteAction,
showPreview: true,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
// Composer
autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode,
// Privacy & Security
sessionTimeout: 0, // Never
// Advanced
debugMode: false,
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
...DEFAULT_SETTINGS,
updateSetting: (key, value) => {
set({ [key]: value });
// Apply font size to document root
if (key === 'fontSize') {
applyFontSize(value as FontSize);
}
// Apply list density to document root
if (key === 'listDensity') {
applyListDensity(value as ListDensity);
}
// Apply animations to document root
if (key === 'animationsEnabled') {
applyAnimations(value as boolean);
}
},
resetToDefaults: () => {
set(DEFAULT_SETTINGS);
applyFontSize(DEFAULT_SETTINGS.fontSize);
applyListDensity(DEFAULT_SETTINGS.listDensity);
applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
},
exportSettings: () => {
const state = get();
const settings = {
fontSize: state.fontSize,
listDensity: state.listDensity,
animationsEnabled: state.animationsEnabled,
dateFormat: state.dateFormat,
timeFormat: state.timeFormat,
firstDayOfWeek: state.firstDayOfWeek,
markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction,
showPreview: state.showPreview,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
sessionTimeout: state.sessionTimeout,
debugMode: state.debugMode,
};
return JSON.stringify(settings, null, 2);
},
importSettings: (json: string) => {
try {
const settings = JSON.parse(json);
// Validate settings
if (typeof settings !== 'object' || settings === null) {
return false;
}
// Apply settings
Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) {
set({ [key]: settings[key] });
}
});
// Apply visual settings
applyFontSize(get().fontSize);
applyListDensity(get().listDensity);
applyAnimations(get().animationsEnabled);
return true;
} catch (error) {
console.error('Failed to import settings:', error);
return false;
}
},
}),
{
name: 'settings-storage',
version: 1,
}
)
);
// Helper functions to apply settings to DOM
function applyFontSize(size: FontSize) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const sizeMap = {
small: '14px',
medium: '16px',
large: '18px',
};
root.style.setProperty('--font-size-base', sizeMap[size]);
}
function applyListDensity(density: ListDensity) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const densityMap = {
compact: '32px',
regular: '48px',
comfortable: '64px',
};
root.style.setProperty('--list-item-height', densityMap[density]);
}
function applyAnimations(enabled: boolean) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (enabled) {
root.style.removeProperty('--transition-duration');
} else {
root.style.setProperty('--transition-duration', '0s');
}
}
// Initialize settings on load
if (typeof window !== 'undefined') {
const store = useSettingsStore.getState();
applyFontSize(store.fontSize);
applyListDensity(store.listDensity);
applyAnimations(store.animationsEnabled);
}
+89
View File
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark' | 'system';
interface ThemeState {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
initializeTheme: () => void;
}
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const applyTheme = (theme: 'light' | 'dark') => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Ensure both classes are handled properly
if (theme === 'dark') {
root.classList.remove('light');
root.classList.add('dark');
} else {
root.classList.remove('dark');
root.classList.add('light');
}
// Store in localStorage for immediate access
localStorage.setItem('theme-applied', theme);
};
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
resolvedTheme: 'light',
setTheme: (theme) => {
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
set({ theme, resolvedTheme });
},
toggleTheme: () => {
const { theme } = get();
const nextTheme: Theme =
theme === 'light' ? 'dark' :
theme === 'dark' ? 'system' : 'light';
get().setTheme(nextTheme);
},
initializeTheme: () => {
const { theme } = get();
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
set({ resolvedTheme });
// Listen for system theme changes
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const { theme } = get();
if (theme === 'system') {
const newResolvedTheme = getSystemTheme();
applyTheme(newResolvedTheme);
set({ resolvedTheme: newResolvedTheme });
}
};
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else {
// Fallback for older browsers
mediaQuery.addListener(handleChange);
}
}
},
}),
{
name: 'theme-storage',
partialize: (state) => ({ theme: state.theme }),
}
)
);
+52
View File
@@ -0,0 +1,52 @@
import { create } from "zustand";
import { Toast } from "@/components/ui/toast";
interface ToastStore {
toasts: Toast[];
addToast: (toast: Omit<Toast, "id">) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
}
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
addToast: (toast) => {
const id = Math.random().toString(36).substring(2, 11);
const newToast: Toast = {
...toast,
id,
duration: toast.duration ?? 5000, // Default 5 seconds
};
set((state) => ({
toasts: [...state.toasts, newToast],
}));
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
},
clearToasts: () => {
set({ toasts: [] });
},
}));
// Helper functions for common toast types
export const toast = {
success: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "success", title, message });
},
error: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "error", title, message, duration: 10000 });
},
info: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "info", title, message });
},
warning: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "warning", title, message });
},
};
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { create } from "zustand";
export type ActiveView = "sidebar" | "list" | "viewer";
interface UIState {
// Mobile view state
activeView: ActiveView;
sidebarOpen: boolean;
// Device detection (hydrated client-side)
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
// Actions
setActiveView: (view: ActiveView) => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
// Navigation helpers
showEmailList: () => void;
showEmailViewer: () => void;
goBack: () => void;
}
export const useUIStore = create<UIState>((set, get) => ({
// Initial state (SSR-safe defaults)
activeView: "list",
sidebarOpen: false,
isMobile: false,
isTablet: false,
isDesktop: true,
// Actions
setActiveView: (view) => set({ activeView: view }),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setDeviceType: (isMobile, isTablet, isDesktop) =>
set({ isMobile, isTablet, isDesktop }),
// Navigation helpers for mobile
showEmailList: () => {
const { isMobile } = get();
if (isMobile) {
set({ activeView: "list", sidebarOpen: false });
}
},
showEmailViewer: () => {
const { isMobile } = get();
if (isMobile) {
set({ activeView: "viewer" });
}
},
goBack: () => {
const { activeView, isMobile } = get();
if (!isMobile) return;
if (activeView === "viewer") {
set({ activeView: "list" });
} else if (activeView === "list") {
set({ sidebarOpen: true });
}
},
}));
+39
View File
@@ -0,0 +1,39 @@
import type { Config } from "tailwindcss";
export default {
darkMode: 'class',
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
sans: [
"-apple-system",
"BlinkMacSystemFont",
"Inter",
"system-ui",
"sans-serif",
],
mono: ["JetBrains Mono", "monospace"],
},
animation: {
"fade-in": "fade-in 0.2s ease-out",
"slide-in": "slide-in 0.3s ease-out",
},
keyframes: {
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
"slide-in": {
"0%": { transform: "translateY(-10px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
},
},
},
plugins: [],
} satisfies Config;
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}