feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA

- Contact groups/lists, vCard import/export (RFC 6350), bulk operations
- Advanced search with JMAP filter panel, search chips, cross-mailbox queries
- Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator
- TOTP two-factor authentication support
- Docker multi-stage build with standalone output and docker-compose
- CSP Report-Only headers and security headers via proxy middleware
- Virtual scrolling for large email lists
- Structured server-side logger (text/JSON, configurable level)
- 450+ tests (contacts, vCard, threads, headers, identity, components)
- Playwright E2E framework setup
- Updated README and ROADMAP with all new features
This commit is contained in:
Matthieu MALVACHE
2026-02-16 18:51:30 +01:00
committed by Matthieu MALVACHE
parent 8a5bc9b88d
commit fb414bf2c9
70 changed files with 8160 additions and 547 deletions
+383 -39
View File
@@ -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<View>("list");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(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 (
<div className="flex h-screen bg-background">
<div className="w-80 border-r border-border flex flex-col">
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
</div>
const renderRightPanel = () => {
switch (view) {
case "create":
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
<ContactList
contacts={contacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
supportsSync={supportsSync}
className="flex-1"
/>
</div>
<div className="flex-1">
{view === "create" && (
<ContactForm onSave={handleSaveNew} onCancel={handleCancel} />
)}
{view === "edit" && selectedContact && (
case "edit":
if (!selectedContact) return null;
return (
<ContactForm
contact={selectedContact}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
)}
{(view === "list" || view === "detail") && (
);
case "group-detail":
if (!selectedGroup) return null;
return (
<ContactGroupDetail
group={selectedGroup}
members={selectedGroupMembers}
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember}
onSelectMember={(id) => {
setSelectedContact(id);
setActiveTab("all");
setView("detail");
}}
/>
);
case "group-create":
return (
<ContactGroupForm
individuals={individuals}
onSave={handleSaveGroup}
onCancel={handleCancel}
/>
);
case "group-edit":
if (!selectedGroup) return null;
return (
<ContactGroupForm
group={selectedGroup}
individuals={individuals}
currentMemberIds={selectedGroupMembers.map(m => m.id)}
onSave={handleSaveGroup}
onCancel={handleCancel}
/>
);
case "import":
return (
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={handleCancel}
/>
);
case "bulk-add-to-group":
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("bulk.choose_group")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("bulk.adding_contacts", { count: selectedContactIds.size })}
</p>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border">
{groups.map((group) => {
const gName = getContactDisplayName(group);
const memberCount = group.members
? Object.values(group.members).filter(Boolean).length
: 0;
return (
<button
key={group.id}
onClick={() => handleBulkAddToGroupConfirm(group.id)}
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{gName}</div>
<div className="text-xs text-muted-foreground">
{t("groups.member_count", { count: memberCount })}
</div>
</div>
</button>
);
})}
</div>
<div className="px-6 py-4 border-t border-border">
<Button variant="outline" onClick={handleCancel} className="w-full">
{t("form.cancel")}
</Button>
</div>
</div>
);
default:
return (
<ContactDetail
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
/>
);
}
};
return (
<div className="flex h-screen bg-background">
<div className="w-80 border-r border-border flex flex-col">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setView("import")}
title={t("import.title")}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (contacts.length > 0) {
exportContacts(contacts.filter(c => c.kind !== "group"));
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
}
}}
title={t("export.title")}
>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("all")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
activeTab === "all"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<BookUser className="w-4 h-4" />
{t("tabs.all")}
</button>
<button
onClick={() => setActiveTab("groups")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
activeTab === "groups"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<Users className="w-4 h-4" />
{t("tabs.groups")}
{groups.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
{groups.length}
</span>
)}
</button>
</div>
{activeTab === "all" ? (
<ContactList
contacts={contacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
/>
) : (
<ContactGroupList
groups={groups}
selectedGroupId={selectedGroupId}
onSelectGroup={handleSelectGroup}
onCreateGroup={handleCreateGroup}
searchQuery={searchQuery}
className="flex-1"
/>
)}
</div>
<div className="flex-1">
{renderRightPanel()}
</div>
</div>
);
}
+6 -52
View File
@@ -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 (
<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`}
>
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
{children}
</ThemeProvider>
</IntlProvider>
</body>
</html>
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
{children}
</ThemeProvider>
</IntlProvider>
);
}
+36 -3
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config";
import { Mail, AlertCircle, Loader2, X } from "lucide-react";
import { Mail, AlertCircle, Loader2, X, ShieldCheck } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
@@ -20,6 +20,8 @@ export default function LoginPage() {
username: "",
password: "",
});
const [showTotpField, setShowTotpField] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
@@ -223,7 +225,8 @@ export default function LoginPage() {
const success = await login(
serverUrl,
formData.username,
formData.password
formData.password,
showTotpField && totpCode ? totpCode : undefined
);
if (success) {
@@ -250,7 +253,9 @@ export default function LoginPage() {
<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")}
{error === 'invalid_credentials' && showTotpField
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
</p>
</div>
)}
@@ -315,6 +320,34 @@ export default function LoginPage() {
required
autoComplete="current-password"
/>
{/* TOTP Toggle */}
<button
type="button"
onClick={() => {
setShowTotpField(!showTotpField);
if (showTotpField) setTotpCode("");
}}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ShieldCheck className="w-4 h-4" />
{showTotpField ? t("totp_hide") : t("totp_toggle")}
</button>
{/* TOTP Input */}
{showTotpField && (
<Input
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
/>
)}
</div>
<Button
+32 -1
View File
@@ -27,6 +27,8 @@ import {
ComposerErrorFallback,
} from "@/components/error";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
import { isFilterEmpty } from "@/lib/jmap/search-utils";
export default function Home() {
const router = useRouter();
@@ -77,6 +79,12 @@ export default function Home() {
clearNewEmailNotification,
markAsSpam,
undoSpam,
searchFilters,
isAdvancedSearchOpen,
setSearchFilters,
clearSearchFilters,
toggleAdvancedSearch,
advancedSearch,
} = useEmailStore();
// Play notification sound for new emails
@@ -575,16 +583,27 @@ export default function Home() {
const handleSearch = async (query: string) => {
if (!client) return;
await searchEmails(client, query);
setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else {
await searchEmails(client, query);
}
};
const handleClearSearch = async () => {
setSearchQuery("");
clearSearchFilters();
if (client && selectedMailbox) {
await fetchEmails(client, selectedMailbox);
}
};
const handleAdvancedSearch = async () => {
if (!client) return;
await advancedSearch(client);
};
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
if (!client) return;
@@ -786,6 +805,18 @@ export default function Home() {
}}
/>
<AdvancedSearchPanel
filters={searchFilters}
isOpen={isAdvancedSearchOpen}
onFiltersChange={setSearchFilters}
onClear={() => {
clearSearchFilters();
if (client) advancedSearch(client);
}}
onSearch={handleAdvancedSearch}
onClose={toggleAdvancedSearch}
/>
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
+8 -1
View File
@@ -9,21 +9,27 @@ import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { useAuthStore } from '@/stores/auth-store';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'advanced';
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'advanced';
export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const { client } = useAuthStore();
const [activeTab, setActiveTab] = useState<Tab>('appearance');
const supportsVacation = client?.supportsVacationResponse() ?? false;
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: 'identities', label: t('tabs.identities') },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
{ id: 'advanced', label: t('tabs.advanced') },
];
@@ -82,6 +88,7 @@ export default function SettingsPage() {
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</div>
</div>
+2
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
/**
* Runtime configuration endpoint
@@ -13,6 +14,7 @@ import { NextResponse } from 'next/server';
* 3. Default values
*/
export async function GET() {
logger.debug('Config requested');
return NextResponse.json({
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
+4
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { logger } from '@/lib/logger';
// Health check thresholds
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
@@ -86,6 +87,8 @@ export async function GET(request: NextRequest) {
}
}
logger.info('Health check', { status, detailed });
return NextResponse.json(response, {
status: httpStatus,
headers: {
@@ -96,6 +99,7 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
logger.error('Health check failed', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json(
{
status: 'unhealthy',
+58 -8
View File
@@ -1,11 +1,61 @@
import { ReactNode } from 'react';
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server";
import "./globals.css";
type Props = {
children: ReactNode;
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",
};
// 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;
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale();
const nonce = (await headers()).get("x-nonce") ?? "";
return (
<html lang={locale} suppressHydrationWarning>
<head>
<script
nonce={nonce}
suppressHydrationWarning
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`}
>
{children}
</body>
</html>
);
}