diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5f571036 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.gitignore +.env* +!.env.example +.claude/ +scripts/ +TODO.md +CLAUDE.md +*.md +!README.md diff --git a/.env.example b/.env.example index 5b695bed..418c1a22 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,42 @@ # JMAP Webmail Configuration # Copy this file to .env.local and fill in your values +# ============================================================================= +# Runtime Configuration (recommended for Docker) +# These are read at request time, allowing post-build configuration +# ============================================================================= + # App name displayed in the UI -NEXT_PUBLIC_APP_NAME=JMAP Webmail +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 +JMAP_SERVER_URL=https://your-jmap-server.com + +# ============================================================================= +# Build-time Configuration (legacy, still supported as fallback) +# These are baked into the bundle at build time +# ============================================================================= + +# NEXT_PUBLIC_APP_NAME=JMAP Webmail +# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com + +# ============================================================================= +# Logging Configuration +# ============================================================================= + +# Log format: "text" (colored, human-readable) or "json" (structured, for log aggregation) +LOG_FORMAT=text + +# Log level: "error", "warn", "info", or "debug" +LOG_LEVEL=info + +# ============================================================================= +# Docker Configuration +# ============================================================================= +# When running with Docker, set these in .env.local: +# APP_NAME=My Webmail +# JMAP_SERVER_URL=https://mail.example.com +# +# Then run: +# docker compose up -d diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..b34a9f4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:24-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npx next build --webpack + +FROM node:24-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 29c5bd98..913d005a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Mark as read/unread, star/unstar - Archive and delete with configurable behavior - Color tags/labels for email organization -- Full-text search +- Advanced search with JMAP filter panel, search chips, and cross-mailbox queries +- Virtual scrolling for large email lists ### User Interface - Clean, minimalist three-pane layout @@ -52,6 +53,14 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Contact management with search and filtering - JMAP server sync (RFC 9553/9610) with local fallback - Email autocomplete from contacts in composer +- Contact groups/lists with group expansion in composer +- vCard import/export (RFC 6350) with duplicate detection +- Bulk operations (multi-select, delete, group add, export) + +### Vacation Responder +- JMAP VacationResponse management with date range scheduling +- Dedicated settings tab with message configuration +- Sidebar indicator when vacation auto-reply is active ### Security & Privacy - External content blocked by default @@ -59,14 +68,22 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - HTML sanitization with DOMPurify - SPF/DKIM/DMARC status indicators - No password storage (session-based auth) +- TOTP two-factor authentication support - Shared folder support with proper permissions - Newsletter unsubscribe support (RFC 2369) +- CSP headers and security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) ### Internationalization - 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese - Automatic browser language detection - Persistent language preference +### Deployment +- Docker support with multi-stage build and standalone output +- Runtime environment variables (no rebuild needed for config changes) +- Health check endpoint for container orchestration +- Structured server-side logging (text/JSON format) + ## Tech Stack - **Framework**: [Next.js 16](https://nextjs.org/) with App Router @@ -135,6 +152,19 @@ npm run build npm start ``` +### Docker + +```bash +# Using docker-compose +cp .env.example .env.local +# Edit .env.local with your JMAP_SERVER_URL +docker compose up -d + +# Or build manually +docker build -t jmap-webmail . +docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com jmap-webmail +``` + ## Keyboard Shortcuts | Key | Action | diff --git a/ROADMAP.md b/ROADMAP.md index 76fab466..41d3ede0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,6 +17,7 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Logout functionality - [x] Authentication error handling - [x] JMAP identities for sender address +- [x] TOTP two-factor authentication (Stalwart-compatible) ### JMAP Server Connection - [x] Session establishment and keep-alive @@ -35,6 +36,7 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Delete and archive - [x] Color tags/labels - [x] Full-text search +- [x] Advanced search with JMAP filter panel, search chips, and cross-mailbox queries - [x] Attachment upload and download - [x] Batch operations (multi-select) - [x] Quick reply form @@ -64,6 +66,7 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Loading states and skeletons - [x] Smooth transitions and animations - [x] Infinite scroll pagination +- [x] Virtual scrolling for large email lists - [x] Error boundaries - [x] Settings page with preferences @@ -88,6 +91,8 @@ This document tracks the development status and planned features for JMAP Webmai - [x] WCAG 2.0 Level AA color contrast compliance - [x] Newsletter unsubscribe support (RFC 2369) - [x] XSS attack prevention with comprehensive validation +- [x] CSP Report-Only headers with per-request nonce +- [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy) ### Identity Management - [x] Multiple sender identities (name, email, signature) @@ -104,8 +109,17 @@ This document tracks the development status and planned features for JMAP Webmai - [x] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook) - [x] Email autocomplete from contacts - [x] Contacts integration in email composer (To/Cc/Bcc) +- [x] Contact groups/lists management with JMAP members map +- [x] vCard import/export (RFC 6350 parser/generator, duplicate detection) +- [x] Bulk contact operations (multi-select, delete, group add, export) - [x] i18n support for contacts (all 8 languages) +### Vacation Responder +- [x] JMAP VacationResponse singleton management +- [x] Settings tab with date range and message configuration +- [x] Sidebar indicator when vacation auto-reply is active +- [x] i18n support (all 8 languages) + ### Email Display - [x] Proper email layout without horizontal scroll or clipping - [x] Blocked image container collapsing (no empty spaces in newsletters) @@ -114,48 +128,47 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Unit tests for validation utilities (57 tests) - [x] Unit tests for email sanitization (27 tests) - [x] Unit tests for color transformation (40 tests) +- [x] Unit tests for contact store (56 tests) +- [x] Unit tests for JMAP contact client (41 tests) +- [x] Unit tests for vCard parser (18 tests) +- [x] Unit tests for thread utilities (20 tests) +- [x] Unit tests for email headers (39 tests) +- [x] Component tests (contacts, UI components — 41 tests) +- [x] JMAP client method tests (identity: 20, contacts: 41) - [x] XSS attack vector testing +- [x] Playwright E2E framework setup ### Deployment - [x] Runtime environment variables (Docker-friendly configuration) +- [x] Health check endpoint +- [x] Docker support (multi-stage build, docker-compose, standalone output) +- [x] Structured server-side logger (text/JSON format, configurable level) ## Planned Features -### Address Book (Phase 2) -- [ ] Contact groups/lists management -- [ ] vCard import/export -- [ ] Bulk contact operations - ### Advanced Features - [ ] Email filters and rules - [ ] Calendar integration (JMAP Calendars) - [ ] Email templates -- [ ] Vacation responder settings -- [ ] Advanced search with filters - [ ] Email encryption (PGP/GPG) +- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default) ### Performance Optimizations -- [ ] Virtual scrolling for large lists - [ ] Email content caching - [ ] Bundle size optimization - [ ] Service worker for offline support - [ ] Lazy loading for attachments ### Testing (Remaining) -- [ ] Component tests -- [ ] E2E tests with Playwright +- [ ] E2E tests with real JMAP server - [ ] Accessibility testing - [ ] Performance testing ### Deployment -- [x] Health check endpoint -- [ ] Docker containerization - [ ] Production build optimizations - [ ] Monitoring and logging ### Security Enhancements -- [ ] CSP headers configuration -- [ ] Additional XSS protection layers - [ ] Rate limiting - [ ] CORS configuration diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 5e02c91c..ffbd7f63 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -1,19 +1,34 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ContactList } from "@/components/contacts/contact-list"; import { ContactDetail } from "@/components/contacts/contact-detail"; import { ContactForm } from "@/components/contacts/contact-form"; -import { useContactStore } from "@/stores/contact-store"; +import { ContactGroupList } from "@/components/contacts/contact-group-list"; +import { ContactGroupForm } from "@/components/contacts/contact-group-form"; +import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; +import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; +import { exportContacts } from "@/components/contacts/contact-export"; +import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { toast } from "@/stores/toast-store"; +import { cn } from "@/lib/utils"; import type { ContactCard } from "@/lib/jmap/types"; -type View = "list" | "detail" | "create" | "edit"; +type View = + | "list" + | "detail" + | "create" + | "edit" + | "group-detail" + | "group-create" + | "group-edit" + | "import" + | "bulk-add-to-group"; export default function ContactsPage() { const router = useRouter(); @@ -24,8 +39,11 @@ export default function ContactsPage() { selectedContactId, searchQuery, supportsSync, + activeTab, + selectedContactIds, setSelectedContact, setSearchQuery, + setActiveTab, fetchContacts, createContact, updateContact, @@ -33,9 +51,24 @@ export default function ContactsPage() { addLocalContact, updateLocalContact, deleteLocalContact, + getGroups, + getIndividuals, + getGroupMembers, + createGroup, + updateGroup, + addMembersToGroup, + removeMembersFromGroup, + deleteGroup, + toggleContactSelection, + selectAllContacts, + clearSelection, + bulkDeleteContacts, + bulkAddToGroup, + importContacts, } = useContactStore(); const [view, setView] = useState("list"); + const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); useEffect(() => { @@ -51,10 +84,15 @@ export default function ContactsPage() { } }, [client, supportsSync, fetchContacts]); + const groups = useMemo(() => getGroups(), [contacts]); + const individuals = useMemo(() => getIndividuals(), [contacts]); const selectedContact = contacts.find((c) => c.id === selectedContactId) || null; + const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; + const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; const handleSelectContact = (id: string) => { setSelectedContact(id); + clearSelection(); setView("detail"); }; @@ -79,7 +117,8 @@ export default function ContactsPage() { } toast.success(t("toast.deleted")); setView("list"); - } catch { + } catch (error) { + console.error('Failed to delete contact:', error); toast.error(t("toast.error_delete")); } }; @@ -114,57 +153,362 @@ export default function ContactsPage() { }, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]); const handleCancel = () => { - setView(selectedContact ? "detail" : "list"); + if (view === "group-create" || view === "group-edit") { + setView(selectedGroup ? "group-detail" : "list"); + } else if (view === "import") { + setView("list"); + } else if (view === "bulk-add-to-group") { + setView("list"); + } else { + setView(selectedContact ? "detail" : "list"); + } }; + const handleSelectGroup = (id: string) => { + setSelectedGroupId(id); + setView("group-detail"); + }; + + const handleCreateGroup = () => { + setSelectedGroupId(null); + setView("group-create"); + }; + + const handleEditGroup = () => { + setView("group-edit"); + }; + + const handleDeleteGroup = async () => { + if (!selectedGroup) return; + if (!window.confirm(t("groups.delete_confirm"))) return; + + try { + await deleteGroup(supportsSync && client ? client : null, selectedGroup.id); + toast.success(t("toast.deleted")); + setSelectedGroupId(null); + setView("list"); + } catch (error) { + console.error('Failed to delete group:', error); + toast.error(t("toast.error_delete")); + } + }; + + const handleSaveGroup = useCallback(async (name: string, memberIds: string[]) => { + const jmapClient = supportsSync && client ? client : null; + if (view === "group-edit" && selectedGroup) { + await updateGroup(jmapClient, selectedGroup.id, name); + const currentMemberIds = selectedGroup.members + ? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k]) + : []; + const toAdd = memberIds.filter(id => !currentMemberIds.includes(id)); + const toRemove = currentMemberIds.filter(id => !memberIds.includes(id)); + if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd); + if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove); + toast.success(t("toast.updated")); + setView("group-detail"); + } else { + await createGroup(jmapClient, name, memberIds); + toast.success(t("toast.created")); + setView("list"); + } + }, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]); + + const handleRemoveGroupMember = async (memberId: string) => { + if (!selectedGroup) return; + try { + await removeMembersFromGroup( + supportsSync && client ? client : null, + selectedGroup.id, + [memberId] + ); + toast.success(t("toast.updated")); + } catch (error) { + console.error('Failed to remove group member:', error); + toast.error(t("toast.error_update")); + } + }; + + const handleBulkDelete = async () => { + if (selectedContactIds.size === 0) return; + if (!window.confirm(t("bulk.delete_confirm", { count: selectedContactIds.size }))) return; + + try { + await bulkDeleteContacts( + supportsSync && client ? client : null, + Array.from(selectedContactIds) + ); + toast.success(t("bulk.deleted", { count: selectedContactIds.size })); + setView("list"); + } catch (error) { + console.error('Failed to bulk delete contacts:', error); + toast.error(t("toast.error_delete")); + } + }; + + const handleBulkAddToGroup = () => { + if (selectedContactIds.size === 0) return; + if (groups.length === 0) { + setView("group-create"); + return; + } + setView("bulk-add-to-group"); + }; + + const handleBulkExport = () => { + const toExport = contacts.filter(c => selectedContactIds.has(c.id)); + if (toExport.length > 0) { + exportContacts(toExport); + toast.success(t("export.success", { count: toExport.length })); + clearSelection(); + } + }; + + const handleBulkAddToGroupConfirm = async (groupId: string) => { + try { + await bulkAddToGroup( + supportsSync && client ? client : null, + groupId, + Array.from(selectedContactIds) + ); + toast.success(t("bulk.added_to_group")); + setView("list"); + } catch (error) { + console.error('Failed to add contacts to group:', error); + toast.error(t("toast.error_update")); + } + }; + + const handleImport = useCallback(async (importedContacts: ContactCard[]) => { + return importContacts( + supportsSync && client ? client : null, + importedContacts + ); + }, [supportsSync, client, importContacts]); + if (!isAuthenticated) return null; - return ( -
-
-
- -
+ const renderRightPanel = () => { + switch (view) { + case "create": + return ; - -
- -
- {view === "create" && ( - - )} - {view === "edit" && selectedContact && ( + case "edit": + if (!selectedContact) return null; + return ( - )} - {(view === "list" || view === "detail") && ( + ); + + case "group-detail": + if (!selectedGroup) return null; + return ( + { + setSelectedContact(id); + setActiveTab("all"); + setView("detail"); + }} + /> + ); + + case "group-create": + return ( + + ); + + case "group-edit": + if (!selectedGroup) return null; + return ( + m.id)} + onSave={handleSaveGroup} + onCancel={handleCancel} + /> + ); + + case "import": + return ( + + ); + + case "bulk-add-to-group": + return ( +
+
+

{t("bulk.choose_group")}

+

+ {t("bulk.adding_contacts", { count: selectedContactIds.size })} +

+
+
+ {groups.map((group) => { + const gName = getContactDisplayName(group); + const memberCount = group.members + ? Object.values(group.members).filter(Boolean).length + : 0; + return ( + + ); + })} +
+
+ +
+
+ ); + + default: + return ( + ); + } + }; + + return ( +
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+ + {activeTab === "all" ? ( + + ) : ( + )}
+ +
+ {renderRightPanel()} +
); } diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 11ec4607..a5e0f944 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,39 +1,19 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import { notFound } from "next/navigation"; import { IntlProvider } from "@/components/providers/intl-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { locales } from "@/i18n/routing"; -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 + 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; @@ -42,36 +22,10 @@ export default async function LocaleLayout({ } return ( - - -