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
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
.gitignore
.env*
!.env.example
.claude/
scripts/
TODO.md
CLAUDE.md
*.md
!README.md
+35 -2
View File
@@ -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
+20
View File
@@ -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"]
+31 -1
View File
@@ -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 |
+27 -14
View File
@@ -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
+367 -23
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,26 +153,330 @@ export default function ContactsPage() {
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
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;
const renderRightPanel = () => {
switch (view) {
case "create":
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit":
if (!selectedContact) return null;
return (
<ContactForm
contact={selectedContact}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
);
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="w-full justify-start"
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}
@@ -143,27 +486,28 @@ export default function ContactsPage() {
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">
{view === "create" && (
<ContactForm onSave={handleSaveNew} onCancel={handleCancel} />
)}
{view === "edit" && selectedContact && (
<ContactForm
contact={selectedContact}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
)}
{(view === "list" || view === "detail") && (
<ContactDetail
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
{renderRightPanel()}
</div>
</div>
);
+1 -47
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>
);
}
+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
+31
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;
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',
+57 -7
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>
);
}
@@ -0,0 +1,61 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ContactDetail } from '../contact-detail';
import type { ContactCard } from '@/lib/jmap/types';
const contact: ContactCard = {
id: '1',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
phones: { p0: { number: '+33612345678' } },
organizations: { o0: { name: 'Acme Corp' } },
addresses: { a0: { street: '123 Main St', locality: 'Paris', country: 'France' } },
notes: { n0: { note: 'VIP customer' } },
};
describe('ContactDetail', () => {
it('shows empty state when contact is null', () => {
render(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText('detail.no_contact_selected')).toBeInTheDocument();
});
it('displays the contact name', () => {
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
});
it('displays email addresses as mailto links', () => {
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
const link = screen.getByText('alice@example.com');
expect(link.closest('a')).toHaveAttribute('href', 'mailto:alice@example.com');
});
it('displays phone numbers', () => {
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText('+33612345678')).toBeInTheDocument();
});
it('displays organization name', () => {
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
const matches = screen.getAllByText('Acme Corp');
expect(matches.length).toBeGreaterThanOrEqual(1);
});
it('calls onEdit when edit button is clicked', () => {
const onEdit = vi.fn();
render(<ContactDetail contact={contact} onEdit={onEdit} onDelete={vi.fn()} />);
fireEvent.click(screen.getByText('form.edit_title'));
expect(onEdit).toHaveBeenCalledOnce();
});
it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const trashButtons = screen.getAllByRole('button').filter(
btn => btn.querySelector('svg') && btn.textContent?.trim() === ''
);
fireEvent.click(trashButtons[trashButtons.length - 1]);
expect(onDelete).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,83 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ContactForm } from '../contact-form';
import type { ContactCard } from '@/lib/jmap/types';
const existingContact: ContactCard = {
id: '1',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
phones: { p0: { number: '+33612345678' } },
organizations: { o0: { name: 'Acme Corp' } },
notes: { n0: { note: 'VIP' } },
};
describe('ContactForm', () => {
it('renders create form with empty fields', () => {
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText('create_title')).toBeInTheDocument();
const inputs = screen.getAllByRole('textbox');
const emptyInputs = inputs.filter(i => (i as HTMLInputElement).value === '');
expect(emptyInputs.length).toBeGreaterThan(0);
});
it('renders edit form with pre-populated data', () => {
render(<ContactForm contact={existingContact} onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByText('edit_title')).toBeInTheDocument();
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument();
expect(screen.getByDisplayValue('Smith')).toBeInTheDocument();
});
it('calls onCancel when cancel button is clicked', () => {
const onCancel = vi.fn();
render(<ContactForm onSave={vi.fn()} onCancel={onCancel} />);
fireEvent.click(screen.getByText('cancel'));
expect(onCancel).toHaveBeenCalledOnce();
});
it('shows error on submit with empty name', async () => {
const onSave = vi.fn();
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(screen.getByText('name_required')).toBeInTheDocument();
});
expect(onSave).not.toHaveBeenCalled();
});
it('adds email entry when add button is clicked', () => {
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
const emailInputsBefore = screen.getAllByPlaceholderText('email_placeholder');
fireEvent.click(screen.getByText('add_email'));
const emailInputsAfter = screen.getAllByPlaceholderText('email_placeholder');
expect(emailInputsAfter.length).toBe(emailInputsBefore.length + 1);
});
it('adds phone entry when add button is clicked', () => {
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
const phoneBefore = screen.queryAllByPlaceholderText('phone_placeholder');
fireEvent.click(screen.getByText('add_phone'));
const phoneAfter = screen.getAllByPlaceholderText('phone_placeholder');
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
});
it('submits form data correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: 'Jane' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
const savedData = onSave.mock.calls[0][0];
expect(savedData.name.components).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
);
});
});
@@ -0,0 +1,55 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ContactListItem } from '../contact-list-item';
import type { ContactCard } from '@/lib/jmap/types';
const contact: ContactCard = {
id: '1',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
organizations: { o0: { name: 'Acme Corp' } },
};
const noNameContact: ContactCard = {
id: '2',
addressBookIds: {},
emails: { e0: { address: 'nobody@example.com' } },
};
const _emptyContact: ContactCard = {
id: '3',
addressBookIds: {},
};
describe('ContactListItem', () => {
it('renders contact name and email', () => {
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('renders organization', () => {
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
});
it('applies selected styling', () => {
const { container } = render(<ContactListItem contact={contact} isSelected={true} onClick={vi.fn()} />);
const button = container.querySelector('button');
expect(button?.className).toContain('bg-accent');
});
it('shows email as display name when no name exists', () => {
render(<ContactListItem contact={noNameContact} isSelected={false} onClick={vi.fn()} />);
const matches = screen.getAllByText('nobody@example.com');
expect(matches.length).toBeGreaterThanOrEqual(1);
});
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<ContactListItem contact={contact} isSelected={false} onClick={onClick} />);
fireEvent.click(screen.getByText('Alice Smith'));
expect(onClick).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,101 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ContactList } from '../contact-list';
import type { ContactCard } from '@/lib/jmap/types';
function makeContact(overrides: Partial<ContactCard> & { id: string }): ContactCard {
return {
addressBookIds: {},
...overrides,
};
}
const alice = makeContact({
id: '1',
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
});
const bob = makeContact({
id: '2',
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
emails: { e0: { address: 'bob@example.com' } },
});
const group = makeContact({
id: '3',
kind: 'group',
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
members: { '1': true },
});
const defaultProps = {
contacts: [alice, bob],
selectedContactId: null,
searchQuery: '',
onSearchChange: vi.fn(),
onSelectContact: vi.fn(),
onCreateNew: vi.fn(),
supportsSync: true,
selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(),
onSelectAll: vi.fn(),
onClearSelection: vi.fn(),
onBulkDelete: vi.fn(),
onBulkAddToGroup: vi.fn(),
onBulkExport: vi.fn(),
groups: [],
};
describe('ContactList', () => {
it('renders contact names', () => {
render(<ContactList {...defaultProps} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
});
it('filters contacts by search query', () => {
render(<ContactList {...defaultProps} searchQuery="alice" />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument();
});
it('shows empty state when no contacts match', () => {
render(<ContactList {...defaultProps} contacts={[]} />);
expect(screen.getByText('empty_state')).toBeInTheDocument();
});
it('shows search empty state when search has no results', () => {
render(<ContactList {...defaultProps} searchQuery="zzz" />);
expect(screen.getByText('empty_search')).toBeInTheDocument();
});
it('shows local mode banner when supportsSync is false', () => {
render(<ContactList {...defaultProps} supportsSync={false} />);
expect(screen.getByText('local_mode')).toBeInTheDocument();
});
it('hides local mode banner when supportsSync is true', () => {
render(<ContactList {...defaultProps} supportsSync={true} />);
expect(screen.queryByText('local_mode')).not.toBeInTheDocument();
});
it('calls onCreateNew when create button is clicked', () => {
const onCreateNew = vi.fn();
render(<ContactList {...defaultProps} onCreateNew={onCreateNew} />);
fireEvent.click(screen.getByText('create_new'));
expect(onCreateNew).toHaveBeenCalledOnce();
});
it('shows bulk action bar when contacts are selected', () => {
render(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} />);
expect(screen.getByText('bulk.delete')).toBeInTheDocument();
expect(screen.getByText('bulk.export')).toBeInTheDocument();
});
it('excludes groups from the list', () => {
render(<ContactList {...defaultProps} contacts={[alice, bob, group]} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.queryByText('Team')).not.toBeInTheDocument();
});
});
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { generateVCard } from "@/lib/vcard";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
export function exportContact(contact: ContactCard) {
const vcf = generateVCard([contact]);
const name = getContactDisplayName(contact) || "contact";
downloadVcf(vcf, `${sanitizeFilename(name)}.vcf`);
}
export function exportContacts(contacts: ContactCard[]) {
const vcf = generateVCard(contacts);
downloadVcf(vcf, `contacts-${new Date().toISOString().slice(0, 10)}.vcf`);
}
function downloadVcf(content: string, filename: string) {
const blob = new Blob([content], { type: "text/vcard;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 50);
}
@@ -0,0 +1,111 @@
"use client";
import { useTranslations } from "next-intl";
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
interface ContactGroupDetailProps {
group: ContactCard;
members: ContactCard[];
onEdit: () => void;
onDelete: () => void;
onRemoveMember: (memberId: string) => void;
onSelectMember: (id: string) => void;
className?: string;
}
export function ContactGroupDetail({
group,
members,
onEdit,
onDelete,
onRemoveMember,
onSelectMember,
className,
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
const groupName = getContactDisplayName(group);
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-7 h-7 text-primary" />
</div>
<div>
<h2 className="text-xl font-semibold">{groupName}</h2>
<p className="text-sm text-muted-foreground">
{t("groups.member_count", { count: members.length })}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
<Button
variant="outline"
size="sm"
onClick={onDelete}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="px-6 py-4">
<h3 className="text-sm font-medium text-muted-foreground mb-3">
{t("groups.members_label")}
</h3>
{members.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("groups.no_members")}
</p>
) : (
<div className="space-y-1">
{members.map((member) => {
const mName = getContactDisplayName(member);
const mEmail = getContactPrimaryEmail(member);
return (
<div
key={member.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
>
<button
className="flex items-center gap-3 flex-1 min-w-0 text-left"
onClick={() => onSelectMember(member.id)}
>
<Avatar name={mName} email={mEmail} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{mName}</div>
{mEmail && (
<div className="text-xs text-muted-foreground truncate">{mEmail}</div>
)}
</div>
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => onRemoveMember(member.id)}
>
<UserMinus className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
interface ContactGroupFormProps {
group?: ContactCard | null;
individuals: ContactCard[];
currentMemberIds?: string[];
onSave: (name: string, memberIds: string[]) => Promise<void>;
onCancel: () => void;
}
export function ContactGroupForm({
group,
individuals,
currentMemberIds = [],
onSave,
onCancel,
}: ContactGroupFormProps) {
const t = useTranslations("contacts");
const isEditing = !!group;
const [name, setName] = useState(
group ? getContactDisplayName(group) : ""
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(
new Set(currentMemberIds)
);
const [memberSearch, setMemberSearch] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const filteredIndividuals = useMemo(() => {
if (!memberSearch) return individuals;
const lower = memberSearch.toLowerCase();
return individuals.filter((c) => {
const n = getContactDisplayName(c).toLowerCase();
const e = getContactPrimaryEmail(c).toLowerCase();
return n.includes(lower) || e.includes(lower);
});
}, [individuals, memberSearch]);
const toggleMember = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelectedIds(next);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError(t("groups.name_required"));
return;
}
setIsSaving(true);
try {
await onSave(name.trim(), Array.from(selectedIds));
} catch (err) {
setError(err instanceof Error ? err.message : t("groups.save_failed"));
} finally {
setIsSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">
{isEditing ? t("groups.edit") : t("groups.create")}
</h2>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded">
{error}
</div>
)}
<div>
<label className="text-sm text-muted-foreground mb-1 block">
{t("groups.name_label")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("groups.name_placeholder")}
autoFocus
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-2 block">
{t("groups.members_label")} ({selectedIds.size})
</label>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={t("groups.search_members")}
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="border rounded-md max-h-64 overflow-y-auto divide-y divide-border">
{filteredIndividuals.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
{t("empty_search")}
</div>
) : (
filteredIndividuals.map((contact) => {
const cName = getContactDisplayName(contact);
const cEmail = getContactPrimaryEmail(contact);
const isSelected = selectedIds.has(contact.id);
return (
<button
key={contact.id}
type="button"
onClick={() => toggleMember(contact.id)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
"hover:bg-muted",
isSelected && "bg-primary/5"
)}
>
<div className={cn(
"w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 transition-colors",
isSelected
? "bg-primary border-primary text-primary-foreground"
: "border-border"
)}>
{isSelected && <Check className="w-3 h-3" />}
</div>
<Avatar name={cName} email={cEmail} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{cName}</div>
{cEmail && (
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
)}
</div>
</button>
);
})
)}
</div>
</div>
{selectedIds.size > 0 && (
<div className="flex flex-wrap gap-1.5">
{Array.from(selectedIds).map((id) => {
const contact = individuals.find((c) => c.id === id);
if (!contact) return null;
return (
<span
key={id}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-primary/10 text-primary"
>
{getContactDisplayName(contact)}
<button
type="button"
onClick={() => toggleMember(id)}
className="hover:text-red-600 dark:hover:text-red-400"
>
<X className="w-3 h-3" />
</button>
</span>
);
})}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
{t("form.cancel")}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (isEditing ? t("form.updating") : t("form.creating")) : t("form.save")}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Users, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
interface ContactGroupListProps {
groups: ContactCard[];
selectedGroupId: string | null;
onSelectGroup: (id: string) => void;
onCreateGroup: () => void;
searchQuery: string;
className?: string;
}
export function ContactGroupList({
groups,
selectedGroupId,
onSelectGroup,
onCreateGroup,
searchQuery,
className,
}: ContactGroupListProps) {
const t = useTranslations("contacts");
const filtered = useMemo(() => {
if (!searchQuery) return groups;
const lower = searchQuery.toLowerCase();
return groups.filter((g) =>
getContactDisplayName(g).toLowerCase().includes(lower)
);
}, [groups, searchQuery]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) =>
getContactDisplayName(a).localeCompare(getContactDisplayName(b))
);
}, [filtered]);
return (
<div className={cn("flex flex-col", className)}>
<div className="px-4 py-2 border-b border-border">
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
<Plus className="w-4 h-4 mr-1" />
{t("groups.create")}
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground px-4">
<Users className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm">
{searchQuery ? t("empty_search") : t("groups.empty")}
</p>
</div>
) : (
<div className="divide-y divide-border">
{sorted.map((group) => {
const memberCount = group.members
? Object.values(group.members).filter(Boolean).length
: 0;
return (
<button
key={group.id}
onClick={() => onSelectGroup(group.id)}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
"hover:bg-muted",
group.id === selectedGroupId && "bg-accent text-accent-foreground"
)}
>
<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">
{getContactDisplayName(group)}
</div>
<div className="text-xs text-muted-foreground">
{t("groups.member_count", { count: memberCount })}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,237 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
interface ContactImportDialogProps {
existingContacts: ContactCard[];
onImport: (contacts: ContactCard[]) => Promise<number>;
onClose: () => void;
}
export function ContactImportDialog({
existingContacts,
onImport,
onClose,
}: ContactImportDialogProps) {
const t = useTranslations("contacts");
const fileRef = useRef<HTMLInputElement>(null);
const [parsed, setParsed] = useState<ContactCard[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setResult(null);
if (file.size > 5 * 1024 * 1024) {
setError(t("import.file_too_large"));
return;
}
try {
const text = await file.text();
const contacts = parseVCard(text);
if (contacts.length === 0) {
setError(t("import.no_contacts"));
return;
}
const dupes = detectDuplicates(existingContacts, contacts);
setParsed(contacts);
setDuplicates(dupes);
const initialSelected = new Set<number>();
contacts.forEach((_, idx) => {
if (!dupes.has(idx)) initialSelected.add(idx);
});
setSelected(initialSelected);
} catch (error) {
console.error('Failed to parse vCard:', error);
setError(t("import.parse_error"));
}
}, [existingContacts, t]);
const toggleSelect = (idx: number) => {
const next = new Set(selected);
if (next.has(idx)) {
next.delete(idx);
} else {
next.add(idx);
}
setSelected(next);
};
const selectAll = () => {
setSelected(new Set(parsed.map((_, i) => i)));
};
const deselectAll = () => {
setSelected(new Set());
};
const handleImport = async () => {
const toImport = parsed.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setIsImporting(true);
try {
const count = await onImport(toImport);
setResult(count);
} catch (error) {
console.error('Failed to import contacts:', error);
setError(t("import.failed"));
} finally {
setIsImporting(false);
}
};
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("import.title")}</h2>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{result !== null ? (
<div className="flex flex-col items-center justify-center py-12">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mb-4">
<Check className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<p className="text-sm font-medium">{t("import.success", { count: result })}</p>
<Button variant="outline" size="sm" onClick={onClose} className="mt-4">
{t("import.close")}
</Button>
</div>
) : parsed.length === 0 ? (
<>
<input
ref={fileRef}
type="file"
accept=".vcf,.vcard"
onChange={handleFileChange}
className="hidden"
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
className={cn(
"w-full border-2 border-dashed rounded-lg py-12 px-4",
"flex flex-col items-center gap-3 transition-colors",
"hover:border-primary hover:bg-primary/5",
"text-muted-foreground"
)}
>
<Upload className="w-8 h-8" />
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
<p className="text-xs">{t("import.file_types")}</p>
</button>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
</>
) : (
<>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{t("import.found", { count: parsed.length })}
</p>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={selectAll}>
{t("import.select_all")}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll}>
{t("import.deselect_all")}
</Button>
</div>
</div>
<div className="border rounded-md divide-y divide-border max-h-96 overflow-y-auto">
{parsed.map((contact, idx) => {
const cName = getContactDisplayName(contact);
const cEmail = getContactPrimaryEmail(contact);
const isDupe = duplicates.has(idx);
const isSelected = selected.has(idx);
return (
<button
key={idx}
type="button"
onClick={() => toggleSelect(idx)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
isSelected && "bg-primary/5"
)}
>
<div className={cn(
"w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 transition-colors",
isSelected ? "bg-primary border-primary text-primary-foreground" : "border-border"
)}>
{isSelected && <Check className="w-3 h-3" />}
</div>
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
{cEmail && cName && (
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
)}
</div>
{isDupe && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-400 flex-shrink-0">
{t("import.duplicate")}
</span>
)}
</button>
);
})}
</div>
</>
)}
</div>
{parsed.length > 0 && result === null && (
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<p className="text-sm text-muted-foreground">
{t("import.selected", { count: selected.size })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} disabled={isImporting}>
{t("form.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
{isImporting ? t("import.importing") : t("import.import_button")}
</Button>
</div>
</div>
)}
</div>
);
}
+95 -4
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, Plus, BookUser, Info } from "lucide-react";
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
@@ -19,6 +19,13 @@ interface ContactListProps {
onCreateNew: () => void;
supportsSync: boolean;
className?: string;
selectedContactIds: Set<string>;
onToggleSelection: (id: string) => void;
onSelectAll: (ids: string[]) => void;
onClearSelection: () => void;
onBulkDelete: () => void;
onBulkAddToGroup: () => void;
onBulkExport: () => void;
}
export function ContactList({
@@ -30,13 +37,21 @@ export function ContactList({
onCreateNew,
supportsSync,
className,
selectedContactIds,
onToggleSelection,
onSelectAll,
onClearSelection,
onBulkDelete,
onBulkAddToGroup,
onBulkExport,
}: ContactListProps) {
const t = useTranslations("contacts");
const filtered = useMemo(() => {
if (!searchQuery) return contacts;
const individuals = contacts.filter(c => c.kind !== "group");
if (!searchQuery) return individuals;
const lower = searchQuery.toLowerCase();
return contacts.filter((c) => {
return individuals.filter((c) => {
const name = getContactDisplayName(c).toLowerCase();
const emails = c.emails
? Object.values(c.emails).map((e) => e.address.toLowerCase())
@@ -55,6 +70,9 @@ export function ContactList({
});
}, [filtered]);
const hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="px-4 py-3 border-b border-border space-y-3">
@@ -84,6 +102,60 @@ export function ContactList({
)}
</div>
{hasSelection && (
<div className="px-3 py-2 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-muted-foreground">
{t("bulk.selected", { count: selectedContactIds.size })}
</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
<Users className="w-3.5 h-3.5 mr-1" />
{t("bulk.add_to_group")}
</Button>
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
<Download className="w-3.5 h-3.5 mr-1" />
{t("bulk.export")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onBulkDelete}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
{t("bulk.delete")}
</Button>
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
<X className="w-3.5 h-3.5" />
</Button>
</div>
)}
{sorted.length > 0 && (
<div className="px-4 py-1.5 border-b border-border flex items-center">
<button
onClick={() => {
if (allSelected) {
onClearSelection();
} else {
onSelectAll(sorted.map(c => c.id));
}
}}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
allSelected
? "bg-primary border-primary text-primary-foreground"
: "border-border"
)}>
{allSelected && <Check className="w-2.5 h-2.5" />}
</div>
{t("bulk.select_all")}
</button>
</div>
)}
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground px-4">
@@ -95,12 +167,31 @@ export function ContactList({
) : (
<div className="divide-y divide-border">
{sorted.map((contact) => (
<div key={contact.id} className="flex items-center">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSelection(contact.id);
}}
className="pl-4 pr-1 py-3 flex-shrink-0"
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
selectedContactIds.has(contact.id)
? "bg-primary border-primary text-primary-foreground"
: "border-border hover:border-muted-foreground"
)}>
{selectedContactIds.has(contact.id) && <Check className="w-2.5 h-2.5" />}
</div>
</button>
<div className="flex-1 min-w-0">
<ContactListItem
key={contact.id}
contact={contact}
isSelected={contact.id === selectedContactId}
onClick={() => onSelectContact(contact.id)}
/>
</div>
</div>
))}
</div>
)}
+98 -39
View File
@@ -9,9 +9,13 @@ 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 { useSettingsStore } from "@/stores/settings-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual";
import { SearchChips } from "@/components/search/search-chips";
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
interface EmailListProps {
emails: Email[];
@@ -19,9 +23,7 @@ interface EmailListProps {
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;
@@ -76,20 +78,37 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
searchFilters,
setSearchFilters,
clearSearchFilters,
advancedSearch,
} = 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 parentRef = useRef<HTMLDivElement>(null);
const listDensity = useSettingsStore((state) => state.listDensity);
const showPreview = useSettingsStore((state) => state.showPreview);
const estimateSize = useCallback(() => {
const base = { compact: 72, regular: 88, comfortable: 104 }[listDensity];
return showPreview ? base + 40 : base;
}, [listDensity, showPreview]);
const virtualizer = useVirtualizer({
count: threadGroups.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 5,
getItemKey: (index) => threadGroups[index]?.threadId ?? String(index),
});
const LoadingSkeleton = () => (
<div className="animate-in fade-in duration-200">
{[...Array(8)].map((_, i) => (
@@ -119,7 +138,7 @@ export function EmailList({
try {
await batchMarkAsRead(client, read);
} finally {
setTimeout(() => setIsProcessing(false), 500); // Small delay for visual feedback
setTimeout(() => setIsProcessing(false), 500);
}
};
@@ -133,52 +152,56 @@ export function EmailList({
}
};
// 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]);
// Range-based load more: trigger when last visible item is near the end
const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) {
handleLoadMore();
}
},
{ threshold: 0.1 }
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
// Scroll to the thread group containing the selected email
useEffect(() => {
if (!selectedEmailId) return;
const index = threadGroups.findIndex(thread =>
thread.latestEmail.id === selectedEmailId ||
thread.emails.some(e => e.id === selectedEmailId)
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
if (index >= 0) {
virtualizer.scrollToIndex(index, { align: 'auto' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmailId]);
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [handleLoadMore]);
// Re-measure all items when density or preview settings change
useEffect(() => {
virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listDensity, showPreview]);
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Batch Actions Toolbar with smooth transition */}
{/* Batch Actions Toolbar */}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
@@ -249,6 +272,22 @@ export function EmailList({
</div>
</div>
{/* Advanced Search Filter Chips */}
{!isFilterEmpty(searchFilters) && (
<SearchChips
filters={searchFilters}
onRemoveFilter={(key) => {
const resetValue = DEFAULT_SEARCH_FILTERS[key];
setSearchFilters({ [key]: resetValue });
if (client) advancedSearch(client);
}}
onClearAll={() => {
clearSearchFilters();
if (client) advancedSearch(client);
}}
/>
)}
{/* 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">
@@ -281,8 +320,8 @@ export function EmailList({
</div>
{/* Email List */}
<div className="flex-1 overflow-y-auto bg-background relative">
{/* Loading overlay - shows on top of existing emails */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
{/* Loading overlay */}
{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">
@@ -292,7 +331,6 @@ export function EmailList({
</div>
)}
{/* Show skeleton only on initial load (no emails yet) */}
{isLoading && emails.length === 0 ? (
<LoadingSkeleton />
) : emails.length === 0 && !isLoading ? (
@@ -302,10 +340,31 @@ export function EmailList({
<p className="text-sm mt-1 text-muted-foreground">{t('no_emails_description')}</p>
</div>
) : (
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
{threadGroups.map((thread) => (
<>
<div
className={cn("transition-opacity duration-200", isLoading && "opacity-50")}
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const thread = threadGroups[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ThreadListItem
key={thread.threadId}
thread={thread}
isExpanded={expandedThreadIds.has(thread.threadId)}
selectedEmailId={selectedEmailId}
@@ -316,10 +375,12 @@ export function EmailList({
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
/>
))}
</div>
);
})}
</div>
{/* Intersection observer target for infinite scroll - always present */}
<div ref={observerTarget} className="py-4 flex justify-center">
<div 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" />
@@ -332,7 +393,7 @@ export function EmailList({
</div>
)}
</div>
</div>
</>
)}
</div>
@@ -349,7 +410,6 @@ export function EmailList({
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
// Single email actions
onReply={() => onReply?.(contextMenu.data!)}
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
onForward={() => onForward?.(contextMenu.data!)}
@@ -361,7 +421,6 @@ export function EmailList({
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
// Batch actions
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
+219 -244
View File
@@ -1,5 +1,6 @@
"use client";
import React from "react";
import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -16,14 +17,13 @@ interface ThreadListItemProps {
isExpanded: boolean;
selectedEmailId?: string;
isLoading?: boolean;
expandedEmails?: Email[]; // Full thread emails when expanded
expandedEmails?: Email[];
onToggleExpand: () => void;
onEmailSelect: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view
onOpenConversation?: (thread: ThreadGroup) => void;
}
// Color tag mapping
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
@@ -34,245 +34,17 @@ const colorTags = {
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 t = useTranslations('threads');
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" />
{t('loading')}
</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,
}: {
interface SingleEmailItemProps {
email: Email;
selected: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
}) {
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
@@ -283,6 +55,7 @@ function SingleEmailItem({
return (
<div
ref={ref}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
colorTag ? colorTag : (
@@ -303,17 +76,14 @@ function SingleEmailItem({
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}
@@ -321,9 +91,7 @@ function SingleEmailItem({
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(
@@ -353,7 +121,6 @@ function SingleEmailItem({
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
@@ -363,7 +130,6 @@ function SingleEmailItem({
{email.subject || "(no subject)"}
</div>
{/* Third Line: Preview */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
@@ -378,4 +144,213 @@ function SingleEmailItem({
</div>
</div>
);
}
}
);
export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemProps>(
function ThreadListItem({
thread,
isExpanded,
selectedEmailId,
isLoading = false,
expandedEmails,
onToggleExpand,
onEmailSelect,
onContextMenu,
onOpenConversation,
}, ref) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
const threadColor = getThreadColorTag(thread.emails);
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
if (emailCount === 1) {
return (
<SingleEmailItem
ref={ref}
email={latestEmail}
selected={selectedEmailId === latestEmail.id}
onClick={() => onEmailSelect(latestEmail)}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
/>
);
}
const emailsToShow = expandedEmails || thread.emails;
const handleHeaderClick = (e: React.MouseEvent) => {
if (isMobile && onOpenConversation) {
onOpenConversation(thread);
return;
}
const target = e.target as HTMLElement;
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
if (!isExpanded) {
onToggleExpand();
}
onEmailSelect(latestEmail);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, latestEmail);
};
return (
<div ref={ref} className="border-b border-border">
<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)'
}}>
{!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>
)}
{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
name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
<div className="flex-1 min-w-0">
<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>
<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>
<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>
{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>
{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" />
{t('loading')}
</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>
);
}
);
+62 -2
View File
@@ -25,12 +25,17 @@ import {
Users,
User,
BookUser,
Palmtree,
SlidersHorizontal,
X,
} 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";
import { useEmailStore } from "@/stores/email-store";
import { activeFilterCount } from "@/lib/jmap/search-utils";
import { useVacationStore } from "@/stores/vacation-store";
import { toast } from "@/stores/toast-store";
interface SidebarProps {
@@ -222,6 +227,57 @@ function MailboxTreeItem({
);
}
function VacationIndicator() {
const t = useTranslations('sidebar');
const { isEnabled, isSupported } = useVacationStore();
if (!isSupported || !isEnabled) return null;
return (
<span
className="relative group"
title={t("vacation_active")}
>
<Palmtree className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400" />
<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"
)}>
{t("vacation_active")}
</span>
</span>
);
}
function AdvancedSearchToggle() {
const tSearch = useTranslations("advanced_search");
const { searchFilters, isAdvancedSearchOpen, toggleAdvancedSearch } = useEmailStore();
const filterCount = activeFilterCount(searchFilters);
return (
<button
type="button"
onClick={toggleAdvancedSearch}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isAdvancedSearchOpen || filterCount > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={tSearch("toggle_filters")}
>
<SlidersHorizontal className="w-4 h-4" />
{filterCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{filterCount}
</span>
)}
</button>
);
}
export function Sidebar({
mailboxes = [],
selectedMailbox = "",
@@ -369,10 +425,11 @@ export function Sidebar({
)}
</div>
{/* Search */}
{/* Search + Advanced Filter Toggle */}
{!isCollapsed && (
<div className="px-4 py-3">
<form onSubmit={handleSearch} className="relative">
<div className="flex items-center gap-1.5">
<form onSubmit={handleSearch} className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
@@ -396,6 +453,8 @@ export function Sidebar({
</button>
)}
</form>
<AdvancedSearchToggle />
</div>
</div>
)}
@@ -505,6 +564,7 @@ export function Sidebar({
<span className="flex items-center gap-2">
<Menu className="w-4 h-4" />
Menu
<VacationIndicator />
{/* Push Connection Status Indicator */}
<span
className="relative group"
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Paperclip,
Star,
Mail,
MailOpen,
X,
RotateCcw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { SearchFilters } from "@/lib/jmap/search-utils";
interface AdvancedSearchPanelProps {
filters: SearchFilters;
isOpen: boolean;
onFiltersChange: (filters: Partial<SearchFilters>) => void;
onClear: () => void;
onSearch: () => void;
onClose: () => void;
}
export function AdvancedSearchPanel({
filters,
isOpen,
onFiltersChange,
onClear,
onSearch,
onClose,
}: AdvancedSearchPanelProps) {
const t = useTranslations("advanced_search");
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const debouncedSearch = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
onSearch();
}, 300);
}, [onSearch]);
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
const handleTextChange = (field: keyof SearchFilters, value: string) => {
onFiltersChange({ [field]: value });
debouncedSearch();
};
const handleToggle = (field: "hasAttachment" | "isUnread" | "isStarred", current: boolean | null) => {
const next = current === null ? true : current === true ? false : null;
onFiltersChange({ [field]: next });
onSearch();
};
const handleDateChange = (field: "dateAfter" | "dateBefore", value: string) => {
onFiltersChange({ [field]: value });
onSearch();
};
const handleClear = () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
onClear();
};
if (!isOpen) return null;
return (
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{t("title")}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={handleClear} className="h-7 px-2 text-xs">
<RotateCcw className="w-3 h-3 mr-1" />
{t("clear")}
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7">
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("from")}</label>
<Input
value={filters.from}
onChange={(e) => handleTextChange("from", e.target.value)}
placeholder={t("from_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("to")}</label>
<Input
value={filters.to}
onChange={(e) => handleTextChange("to", e.target.value)}
placeholder={t("to_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("subject")}</label>
<Input
value={filters.subject}
onChange={(e) => handleTextChange("subject", e.target.value)}
placeholder={t("subject_placeholder")}
className="h-8 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("date_after")}</label>
<Input
type="date"
value={filters.dateAfter}
onChange={(e) => handleDateChange("dateAfter", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("date_before")}</label>
<Input
type="date"
value={filters.dateBefore}
onChange={(e) => handleDateChange("dateBefore", e.target.value)}
className="h-8 text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ToggleFilterButton
icon={<Paperclip className="w-3.5 h-3.5" />}
label={t("has_attachment")}
value={filters.hasAttachment}
onClick={() => handleToggle("hasAttachment", filters.hasAttachment)}
/>
<ToggleFilterButton
icon={<Star className="w-3.5 h-3.5" />}
label={t("starred")}
value={filters.isStarred}
onClick={() => handleToggle("isStarred", filters.isStarred)}
/>
<ToggleFilterButton
icon={filters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
label={filters.isUnread === false ? t("read") : t("unread")}
value={filters.isUnread}
onClick={() => handleToggle("isUnread", filters.isUnread)}
/>
</div>
</div>
</div>
);
}
function ToggleFilterButton({
icon,
label,
value,
onClick,
}: {
icon: React.ReactNode;
label: string;
value: boolean | null;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
value === true && "bg-primary/10 border-primary/30 text-primary",
value === false && "bg-muted border-border text-muted-foreground line-through",
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
)}
>
{icon}
{label}
</button>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { SearchFilters } from "@/lib/jmap/search-utils";
interface SearchChipsProps {
filters: SearchFilters;
onRemoveFilter: (key: keyof SearchFilters) => void;
onClearAll: () => void;
className?: string;
}
export function SearchChips({
filters,
onRemoveFilter,
onClearAll,
className,
}: SearchChipsProps) {
const t = useTranslations("advanced_search");
const chips: { key: keyof SearchFilters; label: string; value: string }[] = [];
if (filters.from) {
chips.push({ key: "from", label: t("from"), value: filters.from });
}
if (filters.to) {
chips.push({ key: "to", label: t("to"), value: filters.to });
}
if (filters.subject) {
chips.push({ key: "subject", label: t("subject"), value: filters.subject });
}
if (filters.body) {
chips.push({ key: "body", label: t("body"), value: filters.body });
}
if (filters.hasAttachment !== null) {
chips.push({
key: "hasAttachment",
label: t("has_attachment"),
value: filters.hasAttachment ? t("yes") : t("no"),
});
}
if (filters.dateAfter) {
chips.push({ key: "dateAfter", label: t("date_after"), value: filters.dateAfter });
}
if (filters.dateBefore) {
chips.push({ key: "dateBefore", label: t("date_before"), value: filters.dateBefore });
}
if (filters.isUnread !== null) {
chips.push({
key: "isUnread",
label: filters.isUnread ? t("unread") : t("read"),
value: "",
});
}
if (filters.isStarred !== null) {
chips.push({
key: "isStarred",
label: t("starred"),
value: filters.isStarred ? t("yes") : t("no"),
});
}
if (chips.length === 0) return null;
return (
<div className={cn("px-4 py-2 border-b border-border bg-muted/20 flex items-center gap-2 flex-wrap", className)}>
{chips.map((chip) => (
<span
key={chip.key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary border border-primary/20"
>
<span className="font-medium">{chip.label}</span>
{chip.value && (
<>
<span className="text-primary/60">:</span>
<span className="max-w-24 truncate">{chip.value}</span>
</>
)}
<button
type="button"
onClick={() => onRemoveFilter(chip.key)}
className="ml-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
{chips.length > 1 && (
<button
type="button"
onClick={onClearAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{t("clear_all")}
</button>
)}
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useAuthStore } from '@/stores/auth-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
function utcToLocalDatetime(utcIso: string): string {
const d = new Date(utcIso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function VacationSettings() {
const t = useTranslations('settings.vacation');
const tNotifications = useTranslations('notifications');
const { client } = useAuthStore();
const {
isEnabled,
fromDate,
toDate,
subject,
textBody,
isLoading,
isSaving,
error,
isSupported,
fetchVacationResponse,
updateVacationResponse,
} = useVacationStore();
const [localEnabled, setLocalEnabled] = useState(isEnabled);
const [localFromDate, setLocalFromDate] = useState(fromDate || '');
const [localToDate, setLocalToDate] = useState(toDate || '');
const [localSubject, setLocalSubject] = useState(subject);
const [localTextBody, setLocalTextBody] = useState(textBody);
const [showPreview, setShowPreview] = useState(false);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
useEffect(() => {
if (client && isSupported) {
void fetchVacationResponse(client);
}
}, [client, isSupported, fetchVacationResponse]);
useEffect(() => {
setLocalEnabled(isEnabled);
setLocalFromDate(fromDate || '');
setLocalToDate(toDate || '');
setLocalSubject(subject);
setLocalTextBody(textBody);
}, [isEnabled, fromDate, toDate, subject, textBody]);
const validate = useCallback(() => {
const warnings: string[] = [];
if (localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) {
warnings.push(t('warnings.end_before_start'));
}
if (localFromDate && new Date(localFromDate) < new Date()) {
warnings.push(t('warnings.start_in_past'));
}
if (localEnabled && !localTextBody.trim()) {
warnings.push(t('warnings.empty_body'));
}
setValidationWarnings(warnings);
return warnings;
}, [localFromDate, localToDate, localEnabled, localTextBody, t]);
useEffect(() => {
validate();
}, [validate]);
const hasChanges =
localEnabled !== isEnabled ||
(localFromDate || null) !== (fromDate || null) ||
(localToDate || null) !== (toDate || null) ||
localSubject !== subject ||
localTextBody !== textBody;
const hasBlockingError = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
const handleSave = async () => {
if (!client) return;
validate();
if (hasBlockingError) return;
try {
await updateVacationResponse(client, {
isEnabled: localEnabled,
fromDate: localFromDate || null,
toDate: localToDate || null,
subject: localSubject,
textBody: localTextBody,
});
toast.success(tNotifications('vacation_saved'));
} catch (error) {
console.error('Failed to save vacation response:', error);
toast.error(tNotifications('vacation_save_failed'));
}
};
if (!isSupported) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="text-sm text-muted-foreground py-4">
{t('not_supported')}
</div>
</SettingsSection>
);
}
if (isLoading) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t('loading')}
</div>
</SettingsSection>
);
}
if (error) {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="text-sm text-red-600 dark:text-red-400 py-4">
{t('fetch_error')}
</div>
</SettingsSection>
);
}
return (
<div className="space-y-6">
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem
label={t('status.label')}
description={t('status.description')}
>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
localEnabled
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}>
{localEnabled ? t('status.active') : t('status.inactive')}
</span>
<ToggleSwitch checked={localEnabled} onChange={setLocalEnabled} />
</div>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('date_range.title')} description={t('date_range.description')}>
<SettingItem
label={t('date_range.start')}
description={t('date_range.start_description')}
>
<input
type="datetime-local"
value={localFromDate ? utcToLocalDatetime(localFromDate) : ''}
onChange={(e) => setLocalFromDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
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"
/>
</SettingItem>
<SettingItem
label={t('date_range.end')}
description={t('date_range.end_description')}
>
<input
type="datetime-local"
value={localToDate ? utcToLocalDatetime(localToDate) : ''}
onChange={(e) => setLocalToDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
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"
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('message.title')} description={t('message.description')}>
<SettingItem
label={t('message.subject_label')}
description={t('message.subject_description')}
>
<input
type="text"
value={localSubject}
onChange={(e) => setLocalSubject(e.target.value)}
placeholder={t('message.subject_placeholder')}
className="w-64 px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</SettingItem>
<div className="py-3">
<label htmlFor="vacation-body" className="text-sm font-medium text-foreground block mb-1">
{t('message.body_label')}
</label>
<p className="text-xs text-muted-foreground mb-2">
{t('message.body_description')}
</p>
<textarea
id="vacation-body"
value={localTextBody}
onChange={(e) => setLocalTextBody(e.target.value)}
placeholder={t('message.body_placeholder')}
rows={6}
className="w-full px-3 py-2 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
/>
</div>
</SettingsSection>
{localTextBody.trim() && (
<SettingsSection title={t('preview.title')}>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPreview ? t('preview.hide') : t('preview.show')}
</button>
{showPreview && (
<div className="mt-3 p-4 rounded border border-border bg-background">
{localSubject && (
<p className="font-medium text-foreground mb-2">{localSubject}</p>
)}
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
</div>
)}
</SettingsSection>
)}
{validationWarnings.length > 0 && (
<div className="space-y-2">
{validationWarnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving || !hasChanges || hasBlockingError}
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('saving')}
</>
) : (
t('save')
)}
</Button>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Avatar } from '../avatar';
describe('Avatar', () => {
it('renders initials from full name', () => {
const { container } = render(<Avatar name="Alice Smith" />);
expect(container.textContent).toBe('AS');
});
it('renders two letters from single-word name', () => {
const { container } = render(<Avatar name="Alice" />);
expect(container.textContent).toBe('AL');
});
it('renders single letter from email when no name', () => {
const { container } = render(<Avatar email="bob@example.com" />);
expect(container.textContent).toBe('B');
});
it('renders "?" when no name or email', () => {
const { container } = render(<Avatar />);
expect(container.textContent).toBe('?');
});
it('produces consistent background color for same input', () => {
const { container: a } = render(<Avatar name="Alice" />);
const { container: b } = render(<Avatar name="Alice" />);
const colorA = (a.firstChild as HTMLElement).style.backgroundColor;
const colorB = (b.firstChild as HTMLElement).style.backgroundColor;
expect(colorA).toBe(colorB);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from '../button';
describe('Button', () => {
it('renders with children text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByText('Click'));
expect(onClick).toHaveBeenCalledOnce();
});
it('applies disabled state', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByText('Disabled')).toBeDisabled();
});
it('renders different variants without errors', () => {
const { rerender } = render(<Button variant="default">Default</Button>);
expect(screen.getByText('Default')).toBeInTheDocument();
rerender(<Button variant="ghost">Ghost</Button>);
expect(screen.getByText('Ghost')).toBeInTheDocument();
rerender(<Button variant="outline">Outline</Button>);
expect(screen.getByText('Outline')).toBeInTheDocument();
rerender(<Button variant="destructive">Destructive</Button>);
expect(screen.getByText('Destructive')).toBeInTheDocument();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Input } from '../input';
describe('Input', () => {
it('renders with placeholder', () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
});
it('handles value changes', () => {
const onChange = vi.fn();
render(<Input onChange={onChange} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } });
expect(onChange).toHaveBeenCalledOnce();
});
it('applies disabled state', () => {
render(<Input disabled placeholder="Disabled" />);
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled();
});
it('passes type prop', () => {
render(<Input type="password" placeholder="Password" />);
const input = screen.getByPlaceholderText('Password');
expect(input).toHaveAttribute('type', 'password');
});
});
+16
View File
@@ -0,0 +1,16 @@
services:
webmail:
build:
context: .
network: host
ports:
- "3000:3000"
env_file:
- .env.local
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
+17
View File
@@ -0,0 +1,17 @@
import { test, expect } from '@playwright/test';
test.describe('Contacts', () => {
test.skip('navigates to contacts page', async ({ page }) => {
await page.goto('/en/contacts');
await expect(page.locator('text=title')).toBeVisible();
});
test.skip('creates a new contact', async ({ page }) => {
await page.goto('/en/contacts');
await page.click('text=create_new');
await page.fill('input[placeholder="given_name"]', 'Test');
await page.fill('input[placeholder="surname"]', 'User');
await page.fill('input[type="email"]', 'test@example.com');
await page.click('button[type="submit"]');
});
});
+21
View File
@@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test';
test.describe('Email', () => {
test.skip('loads inbox', async ({ page }) => {
await page.goto('/en');
await expect(page.locator('[data-testid="email-list"]')).toBeVisible({ timeout: 15000 });
});
test.skip('opens email from list', async ({ page }) => {
await page.goto('/en');
const firstEmail = page.locator('[data-testid="email-list-item"]').first();
await firstEmail.click();
await expect(page.locator('[data-testid="email-viewer"]')).toBeVisible();
});
test.skip('composes new email', async ({ page }) => {
await page.goto('/en');
await page.keyboard.press('c');
await expect(page.locator('[data-testid="email-composer"]')).toBeVisible();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test';
test.describe('Login', () => {
test('loads login page', async ({ page }) => {
await page.goto('/');
await expect(page.locator('input[type="text"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
});
test('shows error on invalid credentials', async ({ page }) => {
await page.goto('/');
await page.fill('input[type="text"]', 'invalid@test.com');
await page.fill('input[type="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('[role="alert"], .text-red-600, .text-destructive')).toBeVisible({ timeout: 10000 });
});
test.skip('logs in with valid credentials', async ({ page }) => {
await page.goto('/');
await page.fill('input[type="text"]', process.env.TEST_USER || '');
await page.fill('input[type="password"]', process.env.TEST_PASS || '');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/en/, { timeout: 15000 });
});
});
+15
View File
@@ -49,12 +49,27 @@ export default [
},
},
},
{
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
languageOptions: {
globals: {
describe: "readonly",
it: "readonly",
expect: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
vi: "readonly",
test: "readonly",
},
},
},
{
ignores: [
".next/**",
"node_modules/**",
"*.config.js",
"*.config.mjs",
"e2e/**",
],
},
];
+245
View File
@@ -0,0 +1,245 @@
import { describe, it, expect } from 'vitest';
import {
parseAuthenticationResults,
parseSpamScore,
parseReceivedHeaders,
formatBytes,
getSecurityStatus,
parseSpamLLM,
extractListHeaders,
} from '../email-headers';
describe('parseAuthenticationResults', () => {
it('parses SPF pass with domain', () => {
const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com');
expect(result.spf).toEqual({ result: 'pass', domain: 'example.com' });
});
it('parses DKIM pass with domain and selector', () => {
const result = parseAuthenticationResults('dkim=pass header.d=example.com header.s=selector1');
expect(result.dkim).toEqual({ result: 'pass', domain: 'example.com', selector: 'selector1' });
});
it('parses DMARC pass with domain', () => {
const result = parseAuthenticationResults('dmarc=pass header.from=example.com');
expect(result.dmarc).toEqual({ result: 'pass', domain: 'example.com', policy: undefined });
});
it('parses all three together separated by semicolons', () => {
const header = 'spf=pass smtp.mailfrom=example.com; dkim=pass header.d=example.com; dmarc=pass header.from=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('pass');
expect(result.dkim?.result).toBe('pass');
expect(result.dmarc?.result).toBe('pass');
});
it('parses failure results', () => {
expect(parseAuthenticationResults('spf=fail smtp.mailfrom=bad.com').spf?.result).toBe('fail');
expect(parseAuthenticationResults('dkim=fail header.d=bad.com').dkim?.result).toBe('fail');
expect(parseAuthenticationResults('dmarc=fail header.from=bad.com').dmarc?.result).toBe('fail');
});
it('returns empty object for unrecognized header', () => {
expect(parseAuthenticationResults('garbage header value')).toEqual({});
});
it('parses iprev with IP address', () => {
const result = parseAuthenticationResults('iprev=pass policy.iprev=192.168.1.1');
expect(result.iprev).toEqual({ result: 'pass', ip: '192.168.1.1' });
});
it('parses SPF softfail', () => {
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
expect(result.spf?.result).toBe('softfail');
});
});
describe('parseSpamScore', () => {
it('parses X-Spam-Status "No" format', () => {
expect(parseSpamScore('No, score=-0.25')).toEqual({ status: 'no', score: -0.25 });
});
it('parses X-Spam-Status "Yes" format', () => {
expect(parseSpamScore('Yes, score=8.5')).toEqual({ status: 'yes', score: 8.5 });
});
it('extracts plain score and classifies as ham', () => {
expect(parseSpamScore('score=3.2')).toEqual({ score: 3.2, status: 'ham' });
});
it('extracts plain score and classifies as spam when above threshold', () => {
expect(parseSpamScore('score=6.0')).toEqual({ score: 6.0, status: 'spam' });
});
it('returns null for unrecognized format', () => {
expect(parseSpamScore('nothing useful here')).toBeNull();
});
});
describe('parseReceivedHeaders', () => {
it('parses a single received header', () => {
const headers = ['from mail.example.com by mx.example.com with SMTP id abc123; Mon, 15 Jan 2024 10:00:00 +0000'];
const result = parseReceivedHeaders(headers);
expect(result).toHaveLength(1);
expect(result[0].from).toBe('mail.example.com');
expect(result[0].by).toBe('mx.example.com');
expect(result[0].protocol).toBe('SMTP');
expect(result[0].id).toBe('abc123');
expect(result[0].timestamp).toBe('Mon, 15 Jan 2024 10:00:00 +0000');
});
it('handles missing fields gracefully', () => {
const result = parseReceivedHeaders(['from sender.example.com']);
expect(result).toHaveLength(1);
expect(result[0].from).toBe('sender.example.com');
expect(result[0].by).toBe('unknown');
});
it('returns empty array for empty input', () => {
expect(parseReceivedHeaders([])).toEqual([]);
});
it('skips headers with no from or by', () => {
expect(parseReceivedHeaders(['random text without routing info'])).toEqual([]);
});
});
describe('formatBytes', () => {
it('formats 0 bytes', () => {
expect(formatBytes(0)).toBe('0 B');
});
it('formats bytes', () => {
expect(formatBytes(512)).toBe('512.0 B');
});
it('formats kilobytes', () => {
expect(formatBytes(1024)).toBe('1.0 KB');
});
it('formats megabytes', () => {
expect(formatBytes(1048576)).toBe('1.0 MB');
});
it('formats gigabytes', () => {
expect(formatBytes(1073741824)).toBe('1.0 GB');
});
});
describe('getSecurityStatus', () => {
it('returns green for pass', () => {
const status = getSecurityStatus('pass');
expect(status.icon).toBe('check');
expect(status.color).toContain('green');
expect(status.borderColor).toContain('green');
});
it('returns red for fail', () => {
const status = getSecurityStatus('fail');
expect(status.icon).toBe('x');
expect(status.color).toContain('red');
});
it('returns red for permerror', () => {
const status = getSecurityStatus('permerror');
expect(status.icon).toBe('x');
expect(status.color).toContain('red');
});
it('returns amber for softfail', () => {
const status = getSecurityStatus('softfail');
expect(status.icon).toBe('alert');
expect(status.color).toContain('amber');
});
it('returns amber for neutral and temperror', () => {
expect(getSecurityStatus('neutral').icon).toBe('alert');
expect(getSecurityStatus('temperror').icon).toBe('alert');
});
it('returns gray for undefined', () => {
const status = getSecurityStatus(undefined);
expect(status.icon).toBe('minus');
expect(status.color).toContain('gray');
});
});
describe('parseSpamLLM', () => {
it('parses LEGITIMATE verdict', () => {
expect(parseSpamLLM('LEGITIMATE (This is a normal email)')).toEqual({
verdict: 'LEGITIMATE',
explanation: 'This is a normal email',
});
});
it('parses SPAM verdict', () => {
expect(parseSpamLLM('SPAM (Unsolicited bulk message)')).toEqual({
verdict: 'SPAM',
explanation: 'Unsolicited bulk message',
});
});
it('parses SUSPICIOUS verdict', () => {
expect(parseSpamLLM('SUSPICIOUS (Possible phishing attempt)')).toEqual({
verdict: 'SUSPICIOUS',
explanation: 'Possible phishing attempt',
});
});
it('is case-insensitive for verdict keyword', () => {
expect(parseSpamLLM('legitimate (test)')).toEqual({
verdict: 'LEGITIMATE',
explanation: 'test',
});
});
it('returns null for unrecognized format', () => {
expect(parseSpamLLM('some random header')).toBeNull();
expect(parseSpamLLM('')).toBeNull();
});
});
describe('extractListHeaders', () => {
it('extracts List-Id', () => {
const result = extractListHeaders({ 'List-Id': 'My Newsletter <list.example.com>' });
expect(result.listId).toBe('My Newsletter <list.example.com>');
});
it('extracts List-Unsubscribe with HTTP URL', () => {
const result = extractListHeaders({
'List-Unsubscribe': '<https://example.com/unsubscribe?id=123>',
});
expect(result.listUnsubscribe?.http).toBe('https://example.com/unsubscribe?id=123');
expect(result.listUnsubscribe?.preferred).toBe('http');
});
it('extracts List-Unsubscribe with both HTTP and mailto', () => {
const result = extractListHeaders({
'List-Unsubscribe': '<https://example.com/unsub>, <mailto:unsub@example.com>',
});
expect(result.listUnsubscribe?.http).toBe('https://example.com/unsub');
expect(result.listUnsubscribe?.mailto).toBe('mailto:unsub@example.com');
expect(result.listUnsubscribe?.preferred).toBe('http');
});
it('handles array header values', () => {
const result = extractListHeaders({
'List-Id': ['Newsletter <list.example.com>', 'fallback'],
});
expect(result.listId).toBe('Newsletter <list.example.com>');
});
it('returns empty object when no list headers present', () => {
expect(extractListHeaders({})).toEqual({});
expect(extractListHeaders({ 'Subject': 'hello' })).toEqual({});
});
it('extracts List-Help and List-Post', () => {
const result = extractListHeaders({
'List-Help': '<mailto:help@example.com>',
'List-Post': '<mailto:post@example.com>',
});
expect(result.listHelp).toBe('<mailto:help@example.com>');
expect(result.listPost).toBe('<mailto:post@example.com>');
});
});
+561
View File
@@ -0,0 +1,561 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
const mockContact = {
id: 'contact-1',
addressBookIds: { 'ab-1': true },
name: { components: [{ kind: 'given' as const, value: 'John' }, { kind: 'surname' as const, value: 'Doe' }], isOrdered: true },
emails: { e0: { address: 'john@example.com' } },
};
const mockAddressBook = {
id: 'ab-1',
name: 'Default',
isDefault: true,
};
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
});
return client;
}
function mockFetch(response: object, ok = true, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}
function mockFetchOnce(spy: ReturnType<typeof vi.spyOn>, response: object) {
spy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
return spy;
}
describe('JMAPClient contact methods', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('supportsContacts', () => {
it('should return true when contacts capability exists', () => {
const client = createClient();
Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:contacts': {} } });
expect(client.supportsContacts()).toBe(true);
});
it('should return false when contacts capability is missing', () => {
const client = createClient();
Object.assign(client, { capabilities: {} });
expect(client.supportsContacts()).toBe(false);
});
it('should throw when capabilities is undefined', () => {
const client = createClient();
Object.assign(client, { capabilities: undefined });
expect(() => client.supportsContacts()).toThrow();
});
});
describe('getAddressBooks', () => {
it('should return address books from server', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
const result = await client.getAddressBooks();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('ab-1');
expect(result[0].name).toBe('Default');
});
it('should return empty array when no address books', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', { list: [] }, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array when list is missing', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', {}, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
});
describe('getContacts', () => {
it('should return contacts from server', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
const result = await client.getContacts();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('contact-1');
});
it('should filter by addressBookId when provided', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
await client.getContacts('ab-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toEqual({ inAddressBook: 'ab-1' });
});
it('should not include filter when no addressBookId', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
await client.getContacts();
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toBeUndefined();
});
it('should return empty array when no contacts', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
const result = await client.getContacts();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getContacts();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response at index 1', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['SomethingElse', {}, '1'],
],
});
const result = await client.getContacts();
expect(result).toEqual([]);
});
});
describe('getContact', () => {
it('should return a single contact', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
});
const result = await client.getContact('contact-1');
expect(result).not.toBeNull();
expect(result!.id).toBe('contact-1');
});
it('should pass contact id in the request', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
});
await client.getContact('contact-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].ids).toEqual(['contact-1']);
});
it('should return null when contact not found', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/get', { list: [] }, '0']],
});
const result = await client.getContact('nonexistent');
expect(result).toBeNull();
});
it('should return null on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getContact('contact-1');
expect(result).toBeNull();
});
it('should return null for unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getContact('contact-1');
expect(result).toBeNull();
});
});
describe('createContact', () => {
it('should create contact and refetch full object', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// 1: getAddressBooks
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
// 2: ContactCard/set
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
// 3: getContact refetch
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
const result = await client.createContact({ name: mockContact.name, emails: mockContact.emails });
expect(result.id).toBe('new-id');
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it('should skip getAddressBooks when addressBookIds provided', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// 1: ContactCard/set (no getAddressBooks needed)
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
// 2: getContact refetch
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
const result = await client.createContact({
name: mockContact.name,
addressBookIds: { 'ab-1': true },
});
expect(result.id).toBe('new-id');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('should throw on notCreated error with description', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'Missing required fields' } },
}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Missing required fields');
});
it('should throw generic error when notCreated has no description', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'forbidden' } },
}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw when created id is missing', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: {} }, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw when refetch returns null', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [] }, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
});
describe('updateContact', () => {
it('should update contact successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name })).resolves.toBeUndefined();
});
it('should pass updates in the request body', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
});
const updates = { name: { components: [{ kind: 'given' as const, value: 'Jane' }], isOrdered: true } };
await client.updateContact('contact-1', updates);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].update['contact-1']).toEqual(updates);
});
it('should throw on notUpdated error with description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notUpdated: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Contact not found');
});
it('should throw generic error when notUpdated has no description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notUpdated: { 'contact-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Failed to update contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Failed to update contact');
});
});
describe('deleteContact', () => {
it('should delete contact successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
});
await expect(client.deleteContact('contact-1')).resolves.toBeUndefined();
});
it('should pass contact id in destroy array', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
});
await client.deleteContact('contact-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].destroy).toEqual(['contact-1']);
});
it('should throw on notDestroyed error with description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notDestroyed: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Contact not found');
});
it('should throw generic error when notDestroyed has no description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notDestroyed: { 'contact-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Failed to delete contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Failed to delete contact');
});
});
describe('searchContacts', () => {
it('should return matching contacts', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
const result = await client.searchContacts('John');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('contact-1');
});
it('should pass query as text filter', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
await client.searchContacts('Jane');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toEqual({ text: 'Jane' });
});
it('should return empty array when no results', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
const result = await client.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.searchContacts('John');
expect(result).toEqual([]);
});
it('should return empty array for unexpected response at index 1', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['SomethingElse', {}, '1'],
],
});
const result = await client.searchContacts('John');
expect(result).toEqual([]);
});
});
});
+309
View File
@@ -0,0 +1,309 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
const mockIdentity = {
id: 'id-1',
name: 'Test User',
email: 'test@example.com',
mayDelete: true,
};
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
// Set internal state so request() doesn't throw "Not connected"
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
});
return client;
}
function mockFetch(response: object, ok = true, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}
describe('JMAPClient identity methods', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('getIdentities', () => {
it('should return identities from server', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/get', { list: [mockIdentity] }, '0']],
});
const result = await client.getIdentities();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('id-1');
expect(result[0].email).toBe('test@example.com');
});
it('should return empty array when no identities', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/get', { list: [] }, '0']],
});
const result = await client.getIdentities();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getIdentities();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getIdentities();
expect(result).toEqual([]);
});
it('should handle missing list property', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/get', {}, '0']],
});
const result = await client.getIdentities();
expect(result).toEqual([]);
});
});
describe('createIdentity', () => {
it('should create identity and return full object', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// First call: Identity/set returns created id
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
}),
} as Response);
// Second call: getIdentities fetches full object
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
}),
} as Response);
const result = await client.createIdentity('Test User', 'test@example.com');
expect(result.id).toBe('new-id');
});
it('should throw on forbidden error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notCreated: { 'new-identity': { type: 'forbidden' } },
}, '0']],
});
await expect(client.createIdentity('Test', 'test@example.com'))
.rejects.toThrow('not authorized');
});
it('should throw on generic creation error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notCreated: { 'new-identity': { type: 'invalidProperties', description: 'Bad input' } },
}, '0']],
});
await expect(client.createIdentity('Test', 'test@example.com'))
.rejects.toThrow('Bad input');
});
it('should throw on unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.createIdentity('Test', 'test@example.com'))
.rejects.toThrow('unexpected');
});
it('should pass all parameters to the request', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
}),
} as Response);
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
}),
} as Response);
const replyTo = [{ name: 'Reply', email: 'reply@example.com' }];
const bcc = [{ email: 'bcc@example.com' }];
await client.createIdentity('Test', 'test@example.com', replyTo, bcc, 'text sig', '<b>html sig</b>');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
const createArgs = body.methodCalls[0][1].create['new-identity'];
expect(createArgs.name).toBe('Test');
expect(createArgs.email).toBe('test@example.com');
expect(createArgs.replyTo).toEqual(replyTo);
expect(createArgs.bcc).toEqual(bcc);
expect(createArgs.textSignature).toBe('text sig');
expect(createArgs.htmlSignature).toBe('<b>html sig</b>');
});
});
describe('updateIdentity', () => {
it('should update identity successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', { updated: { 'id-1': null } }, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'New Name' })).resolves.toBeUndefined();
});
it('should throw on notFound error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notUpdated: { 'id-1': { type: 'notFound' } },
}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('not found');
});
it('should throw on forbidden error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notUpdated: { 'id-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('not authorized');
});
it('should throw on generic update error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notUpdated: { 'id-1': { type: 'other', description: 'Server error' } },
}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('Server error');
});
it('should throw on unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('unexpected');
});
});
describe('deleteIdentity', () => {
it('should delete identity successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', { destroyed: ['id-1'] }, '0']],
});
await expect(client.deleteIdentity('id-1')).resolves.toBeUndefined();
});
it('should throw on forbidden error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notDestroyed: { 'id-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('cannot be deleted');
});
it('should throw on notFound error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notDestroyed: { 'id-1': { type: 'notFound' } },
}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('not found');
});
it('should throw on generic delete error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notDestroyed: { 'id-1': { type: 'other', description: 'Cannot remove' } },
}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('Cannot remove');
});
it('should throw on unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('unexpected');
});
});
});
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect } from 'vitest';
import {
parseSubAddress,
generateSubAddress,
extractDomain,
suggestTagsForDomain,
isValidTag,
getTagValidationError,
MAX_TAG_LENGTH,
} from '../sub-addressing';
describe('parseSubAddress', () => {
describe('standard addresses', () => {
it('should parse email with tag', () => {
const result = parseSubAddress('user+shopping@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
expect(result.domain).toBe('example.com');
expect(result.localPart).toBe('user+shopping');
expect(result.fullAddress).toBe('user+shopping@example.com');
});
it('should parse email with alphanumeric tag', () => {
const result = parseSubAddress('john+news2024@domain.co.uk');
expect(result.baseUser).toBe('john');
expect(result.tag).toBe('news2024');
expect(result.domain).toBe('domain.co.uk');
});
it('should parse email with dash in tag', () => {
const result = parseSubAddress('alice+my-orders@shop.com');
expect(result.tag).toBe('my-orders');
});
});
describe('no tag', () => {
it('should handle email without plus sign', () => {
const result = parseSubAddress('user@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBeNull();
expect(result.domain).toBe('example.com');
expect(result.localPart).toBe('user');
});
it('should handle dotted local part without tag', () => {
const result = parseSubAddress('first.last@example.com');
expect(result.baseUser).toBe('first.last');
expect(result.tag).toBeNull();
});
});
describe('multiple plus signs', () => {
it('should use first plus as separator', () => {
const result = parseSubAddress('user+tag1+tag2@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('tag1+tag2');
expect(result.localPart).toBe('user+tag1+tag2');
});
});
describe('empty tag', () => {
it('should return null tag for trailing plus', () => {
const result = parseSubAddress('user+@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBeNull();
});
});
describe('edge cases', () => {
it('should handle missing domain', () => {
const result = parseSubAddress('user');
expect(result.localPart).toBe('user');
expect(result.baseUser).toBe('user');
expect(result.tag).toBeNull();
expect(result.domain).toBe('');
});
it('should handle missing local part', () => {
const result = parseSubAddress('@example.com');
expect(result.localPart).toBe('');
expect(result.baseUser).toBe('');
expect(result.tag).toBeNull();
expect(result.domain).toBe('example.com');
});
it('should handle empty string', () => {
const result = parseSubAddress('');
expect(result.localPart).toBe('');
expect(result.baseUser).toBe('');
expect(result.tag).toBeNull();
expect(result.domain).toBe('');
});
it('should preserve full address', () => {
const email = 'test+dev@mail.example.org';
const result = parseSubAddress(email);
expect(result.fullAddress).toBe(email);
});
it('should handle plus at start of local part', () => {
const result = parseSubAddress('+tag@example.com');
expect(result.baseUser).toBe('');
expect(result.tag).toBe('tag');
});
});
});
describe('generateSubAddress', () => {
describe('basic generation', () => {
it('should generate tagged address', () => {
expect(generateSubAddress('user@example.com', 'shopping')).toBe('user+shopping@example.com');
});
it('should lowercase the tag', () => {
expect(generateSubAddress('user@example.com', 'Shopping')).toBe('user+shopping@example.com');
});
it('should allow dashes in tag', () => {
expect(generateSubAddress('user@example.com', 'my-orders')).toBe('user+my-orders@example.com');
});
});
describe('replace existing tag', () => {
it('should replace existing tag with new one', () => {
expect(generateSubAddress('user+old@example.com', 'new')).toBe('user+new@example.com');
});
it('should replace complex existing tag', () => {
expect(generateSubAddress('user+tag1+tag2@example.com', 'fresh')).toBe('user+fresh@example.com');
});
});
describe('empty or invalid tag', () => {
it('should return original email for empty tag', () => {
expect(generateSubAddress('user@example.com', '')).toBe('user@example.com');
});
it('should return original email for tag with only invalid chars', () => {
expect(generateSubAddress('user@example.com', '!@#$%')).toBe('user@example.com');
});
});
describe('tag sanitization', () => {
it('should strip special characters from tag', () => {
expect(generateSubAddress('user@example.com', 'my_tag!')).toBe('user+mytag@example.com');
});
it('should strip spaces from tag', () => {
expect(generateSubAddress('user@example.com', 'my tag')).toBe('user+mytag@example.com');
});
it('should keep alphanumeric and dash', () => {
expect(generateSubAddress('user@example.com', 'valid-tag-123')).toBe('user+valid-tag-123@example.com');
});
});
describe('missing domain', () => {
it('should return original for email without domain', () => {
expect(generateSubAddress('user', 'tag')).toBe('user');
});
it('should return original for email without local part', () => {
expect(generateSubAddress('@example.com', 'tag')).toBe('@example.com');
});
});
});
describe('extractDomain', () => {
it('should extract domain from standard email', () => {
expect(extractDomain('user@example.com')).toBe('example.com');
});
it('should extract domain from sub-addressed email', () => {
expect(extractDomain('user+tag@mail.example.org')).toBe('mail.example.org');
});
it('should normalize domain to lowercase', () => {
expect(extractDomain('user@EXAMPLE.COM')).toBe('example.com');
});
it('should return null for email without @', () => {
expect(extractDomain('nodomain')).toBeNull();
});
it('should return null for empty string', () => {
expect(extractDomain('')).toBeNull();
});
it('should handle multiple @ symbols', () => {
expect(extractDomain('user@host@example.com')).toBe('example.com');
});
});
describe('suggestTagsForDomain', () => {
describe('known domains', () => {
it('should return suggestions for amazon.com', () => {
const tags = suggestTagsForDomain('amazon.com');
expect(tags).toContain('amazon');
expect(tags).toContain('shopping');
expect(tags).toContain('orders');
});
it('should return suggestions for github.com', () => {
const tags = suggestTagsForDomain('github.com');
expect(tags).toContain('github');
expect(tags).toContain('dev');
expect(tags).toContain('notifications');
});
it('should return suggestions for paypal.com', () => {
const tags = suggestTagsForDomain('paypal.com');
expect(tags).toContain('paypal');
expect(tags).toContain('payments');
});
it('should return suggestions for netflix.com', () => {
const tags = suggestTagsForDomain('netflix.com');
expect(tags).toContain('netflix');
expect(tags).toContain('entertainment');
});
it('should return suggestions for regional Amazon domains', () => {
expect(suggestTagsForDomain('amazon.fr')).toContain('amazon');
expect(suggestTagsForDomain('amazon.de')).toContain('shopping');
expect(suggestTagsForDomain('amazon.co.uk')).toContain('orders');
});
});
describe('unknown domains', () => {
it('should return generic suggestions with domain name', () => {
const tags = suggestTagsForDomain('randomsite.com');
expect(tags).toContain('randomsite');
expect(tags).toContain('newsletter');
expect(tags).toContain('registration');
});
it('should extract main domain from multi-part TLD', () => {
const tags = suggestTagsForDomain('unknown.co.uk');
expect(tags[0]).toBe('co');
});
});
describe('subdomains', () => {
it('should extract main domain from subdomain', () => {
const tags = suggestTagsForDomain('mail.google.com');
expect(tags[0]).toBe('google');
});
it('should extract main domain from deep subdomain', () => {
const tags = suggestTagsForDomain('smtp.mail.provider.com');
expect(tags[0]).toBe('provider');
});
});
describe('case-insensitive matching', () => {
it('should match known domains case-insensitively', () => {
expect(suggestTagsForDomain('GITHUB.COM')).toContain('github');
expect(suggestTagsForDomain('GitHub.com')).toContain('github');
});
it('should match regional domains case-insensitively', () => {
expect(suggestTagsForDomain('AMAZON.FR')).toContain('amazon');
});
});
});
describe('isValidTag', () => {
describe('valid tags', () => {
it('should accept lowercase letters', () => {
expect(isValidTag('shopping')).toBe(true);
});
it('should accept uppercase letters', () => {
expect(isValidTag('Shopping')).toBe(true);
});
it('should accept numbers', () => {
expect(isValidTag('tag123')).toBe(true);
});
it('should accept dashes', () => {
expect(isValidTag('my-tag')).toBe(true);
});
it('should accept single character', () => {
expect(isValidTag('a')).toBe(true);
});
it('should accept max length tag', () => {
expect(isValidTag('a'.repeat(MAX_TAG_LENGTH))).toBe(true);
});
});
describe('invalid tags', () => {
it('should reject empty string', () => {
expect(isValidTag('')).toBe(false);
});
it('should reject underscores', () => {
expect(isValidTag('my_tag')).toBe(false);
});
it('should reject dots', () => {
expect(isValidTag('my.tag')).toBe(false);
});
it('should reject spaces', () => {
expect(isValidTag('my tag')).toBe(false);
});
it('should reject special characters', () => {
expect(isValidTag('tag!')).toBe(false);
expect(isValidTag('tag@')).toBe(false);
expect(isValidTag('tag#')).toBe(false);
});
it('should reject tag exceeding max length', () => {
expect(isValidTag('a'.repeat(MAX_TAG_LENGTH + 1))).toBe(false);
});
});
});
describe('getTagValidationError', () => {
it('should return null for valid tag', () => {
expect(getTagValidationError('shopping')).toBeNull();
expect(getTagValidationError('my-tag-123')).toBeNull();
});
it('should return EMPTY for empty string', () => {
expect(getTagValidationError('')).toBe('EMPTY');
});
it('should return TOO_LONG for oversized tag', () => {
expect(getTagValidationError('a'.repeat(MAX_TAG_LENGTH + 1))).toBe('TOO_LONG');
});
it('should return INVALID_CHARS for special characters', () => {
expect(getTagValidationError('tag!')).toBe('INVALID_CHARS');
expect(getTagValidationError('tag with spaces')).toBe('INVALID_CHARS');
expect(getTagValidationError('tag_underscore')).toBe('INVALID_CHARS');
});
it('should check length before characters', () => {
const longInvalid = '!'.repeat(MAX_TAG_LENGTH + 1);
expect(getTagValidationError(longInvalid)).toBe('TOO_LONG');
});
it('should return null for boundary-length valid tag', () => {
expect(getTagValidationError('a'.repeat(MAX_TAG_LENGTH))).toBeNull();
});
it('should return INVALID_CHARS for unicode characters', () => {
expect(getTagValidationError('café')).toBe('INVALID_CHARS');
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
});
});
+242
View File
@@ -0,0 +1,242 @@
import { describe, it, expect } from 'vitest';
import {
groupEmailsByThread,
sortThreadGroups,
getThreadParticipants,
mergeThreadEmails,
getEmailColorTag,
getThreadColorTag,
} from '../thread-utils';
import type { Email, ThreadGroup } from '../jmap/types';
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
describe('groupEmailsByThread', () => {
it('groups emails by threadId', () => {
const emails = [
makeEmail({ id: 'e1', threadId: 'thread-1' }),
makeEmail({ id: 'e2', threadId: 'thread-1' }),
makeEmail({ id: 'e3', threadId: 'thread-2' }),
];
const groups = groupEmailsByThread(emails);
expect(groups).toHaveLength(2);
expect(groups.find(g => g.threadId === 'thread-1')!.emailCount).toBe(2);
expect(groups.find(g => g.threadId === 'thread-2')!.emailCount).toBe(1);
});
it('sorts emails within group by receivedAt descending', () => {
const emails = [
makeEmail({ id: 'e1', threadId: 'thread-1', receivedAt: '2024-01-10T10:00:00Z' }),
makeEmail({ id: 'e2', threadId: 'thread-1', receivedAt: '2024-01-15T10:00:00Z' }),
makeEmail({ id: 'e3', threadId: 'thread-1', receivedAt: '2024-01-12T10:00:00Z' }),
];
const group = groupEmailsByThread(emails)[0];
expect(group.emails[0].id).toBe('e2');
expect(group.emails[1].id).toBe('e3');
expect(group.emails[2].id).toBe('e1');
});
it('sets latestEmail to the newest email', () => {
const emails = [
makeEmail({ id: 'old', threadId: 'thread-1', receivedAt: '2024-01-01T00:00:00Z' }),
makeEmail({ id: 'new', threadId: 'thread-1', receivedAt: '2024-06-01T00:00:00Z' }),
];
expect(groupEmailsByThread(emails)[0].latestEmail.id).toBe('new');
});
it('calculates participantNames from unique senders', () => {
const emails = [
makeEmail({ id: 'e1', from: [{ name: 'Alice', email: 'alice@example.com' }] }),
makeEmail({ id: 'e2', from: [{ name: 'Bob', email: 'bob@example.com' }] }),
makeEmail({ id: 'e3', from: [{ name: 'Alice', email: 'alice@example.com' }] }),
];
const group = groupEmailsByThread(emails)[0];
expect(group.participantNames).toEqual(['Alice', 'Bob']);
});
it('detects hasUnread when an email lacks $seen', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: {} }),
];
expect(groupEmailsByThread(emails)[0].hasUnread).toBe(true);
});
it('detects hasStarred when an email has $flagged', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { $seen: true, $flagged: true } }),
];
expect(groupEmailsByThread(emails)[0].hasStarred).toBe(true);
});
it('detects hasAttachment', () => {
const emails = [
makeEmail({ id: 'e1', hasAttachment: false }),
makeEmail({ id: 'e2', hasAttachment: true }),
];
expect(groupEmailsByThread(emails)[0].hasAttachment).toBe(true);
});
it('returns empty array for empty input', () => {
expect(groupEmailsByThread([])).toEqual([]);
});
it('returns empty array for null/undefined input', () => {
expect(groupEmailsByThread(null as unknown as Email[])).toEqual([]);
expect(groupEmailsByThread(undefined as unknown as Email[])).toEqual([]);
});
});
describe('sortThreadGroups', () => {
it('sorts groups by latestEmail.receivedAt descending', () => {
const groups: ThreadGroup[] = [
{
threadId: 'old',
emails: [makeEmail({ receivedAt: '2024-01-01T00:00:00Z' })],
latestEmail: makeEmail({ receivedAt: '2024-01-01T00:00:00Z' }),
participantNames: ['A'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
emailCount: 1,
},
{
threadId: 'new',
emails: [makeEmail({ receivedAt: '2024-06-01T00:00:00Z' })],
latestEmail: makeEmail({ receivedAt: '2024-06-01T00:00:00Z' }),
participantNames: ['B'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
emailCount: 1,
},
];
const sorted = sortThreadGroups(groups);
expect(sorted[0].threadId).toBe('new');
expect(sorted[1].threadId).toBe('old');
});
});
describe('getThreadParticipants', () => {
it('extracts unique sender names', () => {
const emails = [
makeEmail({ from: [{ name: 'Alice', email: 'alice@example.com' }] }),
makeEmail({ from: [{ name: 'Bob', email: 'bob@example.com' }] }),
makeEmail({ from: [{ name: 'Alice', email: 'alice@example.com' }] }),
];
expect(getThreadParticipants(emails)).toEqual(['Alice', 'Bob']);
});
it('respects maxNames limit', () => {
const emails = [
makeEmail({ from: [{ name: 'A', email: 'a@x.com' }] }),
makeEmail({ from: [{ name: 'B', email: 'b@x.com' }] }),
makeEmail({ from: [{ name: 'C', email: 'c@x.com' }] }),
];
expect(getThreadParticipants(emails, 2)).toEqual(['A', 'B']);
});
it('uses email prefix when name is empty', () => {
const emails = [
makeEmail({ from: [{ name: '', email: 'charlie@example.com' }] }),
];
expect(getThreadParticipants(emails)).toEqual(['charlie']);
});
});
describe('mergeThreadEmails', () => {
it('merges new emails without duplicating existing ones', () => {
const existing: ThreadGroup = {
threadId: 'thread-1',
emails: [
makeEmail({ id: 'e1', receivedAt: '2024-01-10T00:00:00Z' }),
makeEmail({ id: 'e2', receivedAt: '2024-01-09T00:00:00Z' }),
],
latestEmail: makeEmail({ id: 'e1', receivedAt: '2024-01-10T00:00:00Z' }),
participantNames: ['Alice'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
emailCount: 2,
};
const fetched = [
makeEmail({ id: 'e2', receivedAt: '2024-01-09T00:00:00Z' }),
makeEmail({ id: 'e3', receivedAt: '2024-01-11T00:00:00Z', from: [{ name: 'Bob', email: 'bob@example.com' }] }),
];
const merged = mergeThreadEmails(existing, fetched);
expect(merged.emailCount).toBe(3);
expect(merged.emails.map(e => e.id)).toEqual(['e3', 'e1', 'e2']);
});
it('updates thread metadata after merge', () => {
const existing: ThreadGroup = {
threadId: 'thread-1',
emails: [makeEmail({ id: 'e1', keywords: { $seen: true }, hasAttachment: false })],
latestEmail: makeEmail({ id: 'e1' }),
participantNames: ['Alice'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
emailCount: 1,
};
const fetched = [
makeEmail({
id: 'e2',
receivedAt: '2024-06-01T00:00:00Z',
keywords: { $flagged: true },
hasAttachment: true,
from: [{ name: 'Bob', email: 'bob@example.com' }],
}),
];
const merged = mergeThreadEmails(existing, fetched);
expect(merged.latestEmail.id).toBe('e2');
expect(merged.hasUnread).toBe(true);
expect(merged.hasStarred).toBe(true);
expect(merged.hasAttachment).toBe(true);
expect(merged.participantNames).toContain('Bob');
});
});
describe('getEmailColorTag', () => {
it('returns color from $color: keyword', () => {
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
});
it('returns null when no color keyword', () => {
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
});
it('returns null for undefined keywords', () => {
expect(getEmailColorTag(undefined)).toBeNull();
});
});
describe('getThreadColorTag', () => {
it('returns first color found across thread emails', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$color:blue': true } }),
];
expect(getThreadColorTag(emails)).toBe('blue');
});
it('returns null when no emails have color tags', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
];
expect(getThreadColorTag(emails)).toBeNull();
});
});
+393
View File
@@ -0,0 +1,393 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseVCard, generateVCard, detectDuplicates } from "../vcard";
import type { ContactCard } from "@/lib/jmap/types";
beforeEach(() => {
vi.stubGlobal("crypto", { randomUUID: () => "test-uuid" });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("parseVCard", () => {
it("parses single vCard with FN (full name)", () => {
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEMAIL:john@example.com\r\nEND:VCARD`;
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.id).toBe("import-test-uuid");
expect(card.name?.components).toEqual(
expect.arrayContaining([
{ kind: "given", value: "John" },
{ kind: "surname", value: "Doe" },
])
);
expect(card.emails?.e0?.address).toBe("john@example.com");
});
it("parses vCard with N field (structured name with all components)", () => {
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;Michael;Mr.;Jr.\r\nEMAIL:john@example.com\r\nEND:VCARD`;
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const components = result[0].name?.components || [];
expect(components).toEqual([
{ kind: "prefix", value: "Mr." },
{ kind: "given", value: "John" },
{ kind: "additional", value: "Michael" },
{ kind: "surname", value: "Doe" },
{ kind: "suffix", value: "Jr." },
]);
});
it("N field overrides FN when both present", () => {
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nN:Doe;John;;;\r\nEMAIL:john@example.com\r\nEND:VCARD`;
const result = parseVCard(vcf);
// N comes after FN in raw lines, and N always sets card.name (overwrites FN)
const components = result[0].name?.components || [];
expect(components.find((c) => c.kind === "given")?.value).toBe("John");
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
});
it("parses vCard with phone, org, and address", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Jane Smith",
"EMAIL;TYPE=WORK:jane@work.com",
"TEL;TYPE=CELL:+1234567890",
"ORG:Acme Corp;Engineering",
"ADR;TYPE=WORK:;;123 Main St;City;State;12345;US",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.emails?.e0?.address).toBe("jane@work.com");
expect(card.emails?.e0?.contexts).toEqual({ work: true });
expect(card.phones?.p0?.number).toBe("+1234567890");
expect(card.organizations?.o0?.name).toBe("Acme Corp");
expect(card.organizations?.o0?.units).toEqual([{ name: "Engineering" }]);
expect(card.addresses?.a0).toMatchObject({
street: "123 Main St",
locality: "City",
region: "State",
postcode: "12345",
country: "US",
contexts: { work: true },
});
});
it("parses vCard with nickname, notes, and UID", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Bob Builder",
"NICKNAME:Bobby",
"NOTE:Important person",
"UID:abc-123",
"EMAIL:bob@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const card = result[0];
expect(card.nicknames?.n0?.name).toBe("Bobby");
expect(card.notes?.n0?.note).toBe("Important person");
expect(card.uid).toBe("abc-123");
});
it("parses multi-contact vCard file", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Alice",
"EMAIL:alice@example.com",
"END:VCARD",
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Bob",
"EMAIL:bob@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(2);
expect(result[0].emails?.e0?.address).toBe("alice@example.com");
expect(result[1].emails?.e0?.address).toBe("bob@example.com");
});
it("skips malformed vCards without name or email", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"NOTE:Just a note",
"END:VCARD",
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Valid Contact",
"EMAIL:valid@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
expect(result[0].emails?.e0?.address).toBe("valid@example.com");
});
it("parses vCard with group kind and members", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Team Alpha",
"KIND:group",
"MEMBER:urn:uuid:member-1",
"MEMBER:urn:uuid:member-2",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.kind).toBe("group");
expect(card.members).toEqual({ "member-1": true, "member-2": true });
});
it("handles folded lines (continuation with leading space)", () => {
const vcf =
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John\r\n Doe\r\nEMAIL:john@example.com\r\nEND:VCARD";
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
expect(result[0].name?.components).toEqual(
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
);
});
it("handles escaped characters", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Test User",
"NOTE:Line one\\nLine two\\, with comma\\; and semicolon\\\\backslash",
"EMAIL:test@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].notes?.n0?.note).toBe(
"Line one\nLine two, with comma; and semicolon\\backslash"
);
});
it("allows group kind without name or email", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"KIND:group",
"MEMBER:urn:uuid:m1",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
expect(result[0].kind).toBe("group");
});
});
describe("generateVCard", () => {
it("exports single contact with all fields", () => {
const contact: ContactCard = {
id: "c1",
uid: "uid-1",
addressBookIds: { ab1: true },
kind: "individual",
name: {
components: [
{ kind: "prefix", value: "Dr." },
{ kind: "given", value: "Jane" },
{ kind: "additional", value: "Marie" },
{ kind: "surname", value: "Smith" },
{ kind: "suffix", value: "PhD" },
],
isOrdered: true,
},
emails: {
e0: { address: "jane@work.com", contexts: { work: true } },
e1: { address: "jane@home.com", contexts: { private: true } },
},
phones: { p0: { number: "+1234567890", contexts: { work: true } } },
organizations: {
o0: { name: "Acme Corp", units: [{ name: "Engineering" }] },
},
addresses: {
a0: {
street: "123 Main St",
locality: "City",
region: "State",
postcode: "12345",
country: "US",
contexts: { work: true },
},
},
nicknames: { n0: { name: "JJ" } },
notes: { n0: { note: "VIP client" } },
};
const vcf = generateVCard([contact]);
expect(vcf).toContain("BEGIN:VCARD");
expect(vcf).toContain("END:VCARD");
expect(vcf).toContain("VERSION:3.0");
expect(vcf).toContain("UID:uid-1");
expect(vcf).toContain("KIND:individual");
expect(vcf).toContain("FN:Jane Smith");
expect(vcf).toContain("N:Smith;Jane;Marie;Dr.;PhD");
expect(vcf).toContain("NICKNAME:JJ");
expect(vcf).toContain("EMAIL;TYPE=WORK:jane@work.com");
expect(vcf).toContain("EMAIL;TYPE=HOME:jane@home.com");
expect(vcf).toContain("TEL;TYPE=WORK:+1234567890");
expect(vcf).toContain("ORG:Acme Corp;Engineering");
expect(vcf).toContain("ADR;TYPE=WORK:;;123 Main St;City;State;12345;US");
expect(vcf).toContain("NOTE:VIP client");
});
it("produces valid structure for minimal contact", () => {
const contact: ContactCard = {
id: "c2",
addressBookIds: {},
name: {
components: [{ kind: "given", value: "Solo" }],
isOrdered: true,
},
};
const vcf = generateVCard([contact]);
const lines = vcf.split("\r\n");
expect(lines[0]).toBe("BEGIN:VCARD");
expect(lines[1]).toBe("VERSION:3.0");
expect(lines).toContain("FN:Solo");
expect(lines).toContain("N:;Solo;;;");
expect(lines[lines.length - 1]).toBe("END:VCARD");
});
it("encodes special characters in values", () => {
const contact: ContactCard = {
id: "c3",
addressBookIds: {},
name: {
components: [{ kind: "given", value: "Test" }],
isOrdered: true,
},
notes: { n0: { note: "Has comma, semicolon; and newline\nhere" } },
};
const vcf = generateVCard([contact]);
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
});
});
describe("round-trip: parse → generate → parse", () => {
it("produces structurally equivalent data", () => {
const original = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:John Doe",
"N:Doe;John;;;",
"EMAIL;TYPE=WORK:john@work.com",
"TEL:+1234567890",
"ORG:Acme Corp",
"NICKNAME:JD",
"NOTE:A note",
"UID:round-trip-1",
"END:VCARD",
].join("\r\n");
const parsed = parseVCard(original);
const exported = generateVCard(parsed);
const reparsed = parseVCard(exported);
expect(reparsed).toHaveLength(1);
const a = parsed[0];
const b = reparsed[0];
expect(b.name?.components).toEqual(a.name?.components);
expect(b.emails?.e0?.address).toBe(a.emails?.e0?.address);
expect(b.phones?.p0?.number).toBe(a.phones?.p0?.number);
expect(b.organizations?.o0?.name).toBe(a.organizations?.o0?.name);
expect(b.nicknames?.n0?.name).toBe(a.nicknames?.n0?.name);
expect(b.notes?.n0?.note).toBe(a.notes?.n0?.note);
expect(b.uid).toBe(a.uid);
});
});
describe("detectDuplicates", () => {
it("detects duplicates by matching email (case-insensitive)", () => {
const existing: ContactCard[] = [
{
id: "existing-1",
addressBookIds: {},
emails: { e0: { address: "Alice@Example.com" } },
},
];
const incoming: ContactCard[] = [
{
id: "new-1",
addressBookIds: {},
emails: { e0: { address: "alice@example.com" } },
},
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.size).toBe(1);
expect(dupes.get(0)).toBe("existing-1");
});
it("returns empty map when no duplicates", () => {
const existing: ContactCard[] = [
{
id: "existing-1",
addressBookIds: {},
emails: { e0: { address: "alice@example.com" } },
},
];
const incoming: ContactCard[] = [
{
id: "new-1",
addressBookIds: {},
emails: { e0: { address: "bob@example.com" } },
},
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.size).toBe(0);
});
it("handles contacts without emails", () => {
const existing: ContactCard[] = [
{ id: "existing-1", addressBookIds: {} },
];
const incoming: ContactCard[] = [
{ id: "new-1", addressBookIds: {} },
{
id: "new-2",
addressBookIds: {},
emails: { e0: { address: "a@b.com" } },
},
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.size).toBe(0);
});
});
+110 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -866,6 +866,61 @@ export class JMAPClient {
}
}
async advancedSearchEmails(
filter: Record<string, unknown>,
accountId?: string,
limit: number = 50,
position: number = 0
): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter,
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": {
resultOf: "0",
name: "Email/query",
path: "/ids",
},
properties: [
"id",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"from",
"to",
"cc",
"subject",
"preview",
"hasAttachment",
],
}, "1"],
]);
const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || [];
const total = queryResponse?.total || 0;
const hasMore = total > 0
? (position + emails.length) < total
: emails.length === limit;
return { emails, hasMore, total };
} catch (error) {
console.error('Advanced search failed:', error);
throw error;
}
}
// Thread methods for conversation view
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
try {
@@ -1090,6 +1145,60 @@ export class JMAPClient {
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
}
private vacationUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"];
}
async getVacationResponse(): Promise<VacationResponse> {
const response = await this.request([
["VacationResponse/get", {
accountId: this.accountId,
ids: ["singleton"],
}, "0"]
], this.vacationUsing());
if (response.methodResponses?.[0]?.[0] === "VacationResponse/get") {
const list = response.methodResponses[0][1].list || [];
if (list.length > 0) {
return list[0] as VacationResponse;
}
return {
id: "singleton",
isEnabled: false,
fromDate: null,
toDate: null,
subject: "",
textBody: "",
htmlBody: null,
};
}
throw new Error("Failed to fetch vacation response: unexpected server response");
}
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
const response = await this.request([
["VacationResponse/set", {
accountId: this.accountId,
update: {
"singleton": updates,
},
}, "0"]
], this.vacationUsing());
if (response.methodResponses?.[0]?.[0] === "VacationResponse/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.["singleton"]) {
const error = result.notUpdated["singleton"];
throw new Error(error.description || "Failed to update vacation response");
}
return;
}
throw new Error("Failed to update vacation response");
}
async createDraft(
to: string[],
subject: string,
+129
View File
@@ -0,0 +1,129 @@
export interface SearchFilters {
from: string;
to: string;
subject: string;
body: string;
hasAttachment: boolean | null;
dateAfter: string;
dateBefore: string;
isUnread: boolean | null;
isStarred: boolean | null;
}
export const DEFAULT_SEARCH_FILTERS: SearchFilters = {
from: "",
to: "",
subject: "",
body: "",
hasAttachment: null,
dateAfter: "",
dateBefore: "",
isUnread: null,
isStarred: null,
};
export function buildJMAPFilter(
textQuery: string,
filters: SearchFilters,
mailboxId?: string
): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
if (textQuery) {
conditions.push({ text: textQuery });
}
if (filters.from) {
conditions.push({ from: filters.from });
}
if (filters.to) {
conditions.push({ to: filters.to });
}
if (filters.subject) {
conditions.push({ subject: filters.subject });
}
if (filters.body) {
conditions.push({ body: filters.body });
}
if (filters.hasAttachment === true) {
conditions.push({ hasAttachment: true });
} else if (filters.hasAttachment === false) {
conditions.push({ hasAttachment: false });
}
if (filters.dateAfter) {
const date = new Date(filters.dateAfter);
if (!isNaN(date.getTime())) {
conditions.push({ after: date.toISOString() });
}
}
if (filters.dateBefore) {
const endOfDay = new Date(filters.dateBefore);
if (!isNaN(endOfDay.getTime())) {
endOfDay.setHours(23, 59, 59, 999);
conditions.push({ before: endOfDay.toISOString() });
}
}
if (filters.isUnread === true) {
conditions.push({ notKeyword: "$seen" });
} else if (filters.isUnread === false) {
conditions.push({ hasKeyword: "$seen" });
}
if (filters.isStarred === true) {
conditions.push({ hasKeyword: "$flagged" });
} else if (filters.isStarred === false) {
conditions.push({ notKeyword: "$flagged" });
}
if (mailboxId) {
conditions.push({ inMailbox: mailboxId });
}
if (conditions.length === 0) {
return mailboxId ? { inMailbox: mailboxId } : {};
}
if (conditions.length === 1) {
return conditions[0];
}
return {
operator: "AND",
conditions,
};
}
export function isFilterEmpty(filters: SearchFilters): boolean {
return (
!filters.from &&
!filters.to &&
!filters.subject &&
!filters.body &&
filters.hasAttachment === null &&
!filters.dateAfter &&
!filters.dateBefore &&
filters.isUnread === null &&
filters.isStarred === null
);
}
export function activeFilterCount(filters: SearchFilters): number {
let count = 0;
if (filters.from) count++;
if (filters.to) count++;
if (filters.subject) count++;
if (filters.body) count++;
if (filters.hasAttachment !== null) count++;
if (filters.dateAfter) count++;
if (filters.dateBefore) count++;
if (filters.isUnread !== null) count++;
if (filters.isStarred !== null) count++;
return count;
}
+11
View File
@@ -167,6 +167,7 @@ export interface ContactCard {
addresses?: Record<string, ContactAddress>;
nicknames?: Record<string, ContactNickname>;
notes?: Record<string, ContactNote>;
members?: Record<string, boolean>;
created?: string;
updated?: string;
}
@@ -233,6 +234,16 @@ export interface AddressBookRights {
mayDelete: boolean;
}
export interface VacationResponse {
id: string;
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
}
export interface EmailSubmission {
id: string;
identityId: string;
+62
View File
@@ -0,0 +1,62 @@
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
const LEVELS: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 };
const COLORS: Record<LogLevel, string> = {
error: '\x1b[31m',
warn: '\x1b[33m',
info: '\x1b[34m',
debug: '\x1b[90m',
};
const RESET = '\x1b[0m';
function getLevel(): number {
const env = process.env.LOG_LEVEL?.toLowerCase() as LogLevel | undefined;
return env && env in LEVELS ? LEVELS[env] : LEVELS.info;
}
function isJson(): boolean {
return process.env.LOG_FORMAT?.toLowerCase() === 'json';
}
function log(level: LogLevel, message: string, extra?: Record<string, unknown>): void {
if (LEVELS[level] > getLevel()) return;
if (isJson()) {
const entry: Record<string, unknown> = {
timestamp: new Date().toISOString(),
level,
message,
...extra,
};
const out = JSON.stringify(entry);
if (level === 'error' || level === 'warn') {
console.error(out);
} else {
console.log(out);
}
return;
}
const color = COLORS[level];
const tag = `${color}[${level.toUpperCase().padEnd(5)}]${RESET}`;
const ts = new Date().toISOString();
const suffix = extra && Object.keys(extra).length > 0
? ` ${COLORS.debug}${JSON.stringify(extra)}${RESET}`
: '';
if (level === 'error' || level === 'warn') {
console.error(`${tag} ${ts} ${message}${suffix}`);
} else {
console.log(`${tag} ${ts} ${message}${suffix}`);
}
}
export const logger = {
error: (message: string, extra?: Record<string, unknown>) => log('error', message, extra),
warn: (message: string, extra?: Record<string, unknown>) => log('warn', message, extra),
info: (message: string, extra?: Record<string, unknown>) => log('info', message, extra),
debug: (message: string, extra?: Record<string, unknown>) => log('debug', message, extra),
request: (method: string, path: string, status: number, durationMs: number) =>
log('info', `${method} ${path} ${status}`, { method, path, status, durationMs }),
};
+346
View File
@@ -0,0 +1,346 @@
import type { ContactCard, NameComponent } from "@/lib/jmap/types";
function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function decodeValue(raw: string): string {
return raw
.replace(/\\n/gi, "\n")
.replace(/\\,/g, ",")
.replace(/\\;/g, ";")
.replace(/\\\\/g, "\\");
}
function encodeValue(val: string): string {
return val
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n");
}
function parseParams(paramStr: string): Record<string, string> {
const params: Record<string, string> = {};
if (!paramStr) return params;
const parts = paramStr.split(";");
for (const part of parts) {
const eq = part.indexOf("=");
if (eq > 0) {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
} else {
const upper = part.toUpperCase();
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF"].includes(upper)) {
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
}
}
}
return params;
}
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
if (!typeStr) return undefined;
const types = typeStr.toUpperCase().split(",");
const ctx: Record<string, boolean> = {};
if (types.includes("WORK")) ctx.work = true;
if (types.includes("HOME")) ctx.private = true;
if (!ctx.work && !ctx.private) return undefined;
return ctx;
}
function contextToType(contexts: Record<string, boolean> | undefined): string {
if (!contexts) return "";
if (contexts.work) return "WORK";
if (contexts.private) return "HOME";
return "";
}
export function parseVCard(vcfString: string): ContactCard[] {
const text = unfoldLines(vcfString);
const lines = text.split("\n");
const contacts: ContactCard[] = [];
let current: Record<string, string[]> | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.toUpperCase() === "BEGIN:VCARD") {
current = {};
continue;
}
if (trimmed.toUpperCase() === "END:VCARD") {
if (current) {
const card = buildContact(current);
if (card) contacts.push(card);
}
current = null;
continue;
}
if (current) {
const colonIdx = trimmed.indexOf(":");
if (colonIdx < 1) continue;
const keyPart = trimmed.substring(0, colonIdx);
const value = trimmed.substring(colonIdx + 1);
if (!current[keyPart]) current[keyPart] = [];
current[keyPart].push(value);
}
}
return contacts;
}
function buildContact(raw: Record<string, string[]>): ContactCard | null {
const id = `import-${crypto.randomUUID()}`;
const card: ContactCard = { id, addressBookIds: {} };
for (const [fullKey, values] of Object.entries(raw)) {
const semiIdx = fullKey.indexOf(";");
const propName = (semiIdx > 0 ? fullKey.substring(0, semiIdx) : fullKey).toUpperCase();
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
const params = parseParams(paramStr);
for (const rawValue of values) {
const val = decodeValue(rawValue);
switch (propName) {
case "FN":
if (!card.name) {
const parts = val.split(" ");
const components: NameComponent[] = [];
if (parts.length >= 2) {
components.push({ kind: "given", value: parts[0] });
components.push({ kind: "surname", value: parts.slice(1).join(" ") });
} else if (parts.length === 1) {
components.push({ kind: "given", value: parts[0] });
}
card.name = { components, isOrdered: true };
}
break;
case "N": {
const nParts = val.split(";");
const components: NameComponent[] = [];
if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] });
if (nParts[1]) components.push({ kind: "given", value: nParts[1] });
if (nParts[2]) components.push({ kind: "additional", value: nParts[2] });
if (nParts[0]) components.push({ kind: "surname", value: nParts[0] });
if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] });
if (components.length > 0) {
card.name = { components, isOrdered: true };
}
break;
}
case "EMAIL": {
if (!card.emails) card.emails = {};
const idx = Object.keys(card.emails).length;
card.emails[`e${idx}`] = {
address: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "TEL": {
if (!card.phones) card.phones = {};
const idx = Object.keys(card.phones).length;
card.phones[`p${idx}`] = {
number: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "ORG": {
if (!card.organizations) card.organizations = {};
const orgParts = val.split(";").filter(Boolean);
const idx = Object.keys(card.organizations).length;
card.organizations[`o${idx}`] = {
name: orgParts[0],
units: orgParts.slice(1).map(u => ({ name: u })),
};
break;
}
case "ADR": {
if (!card.addresses) card.addresses = {};
const adrParts = val.split(";");
const idx = Object.keys(card.addresses).length;
card.addresses[`a${idx}`] = {
street: adrParts[2] || undefined,
locality: adrParts[3] || undefined,
region: adrParts[4] || undefined,
postcode: adrParts[5] || undefined,
country: adrParts[6] || undefined,
contexts: typeToContext(params.TYPE),
};
break;
}
case "NOTE": {
if (!card.notes) card.notes = {};
const idx = Object.keys(card.notes).length;
card.notes[`n${idx}`] = { note: val };
break;
}
case "NICKNAME": {
if (!card.nicknames) card.nicknames = {};
card.nicknames.n0 = { name: val };
break;
}
case "UID":
card.uid = val;
break;
case "KIND": {
const k = val.toLowerCase();
if (k === "group" || k === "individual" || k === "org") {
card.kind = k;
}
break;
}
case "MEMBER": {
if (!card.members) card.members = {};
const memberUri = val.startsWith("urn:uuid:") ? val.substring(9) : val;
card.members[memberUri] = true;
break;
}
}
}
}
const hasName = card.name && card.name.components.length > 0;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
return card;
}
export function generateVCard(contacts: ContactCard[]): string {
return contacts.map(generateSingleVCard).join("\r\n");
}
function generateSingleVCard(contact: ContactCard): string {
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
if (contact.uid) {
lines.push(`UID:${contact.uid}`);
}
if (contact.kind) {
lines.push(`KIND:${contact.kind}`);
}
const components = contact.name?.components || [];
const given = components.find(c => c.kind === "given")?.value || "";
const surname = components.find(c => c.kind === "surname")?.value || "";
const prefix = components.find(c => c.kind === "prefix")?.value || "";
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
const fn = [given, surname].filter(Boolean).join(" ");
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
}
if (contact.nicknames) {
for (const nick of Object.values(contact.nicknames)) {
lines.push(`NICKNAME:${encodeValue(nick.name)}`);
}
}
if (contact.emails) {
for (const email of Object.values(contact.emails)) {
const type = contextToType(email.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`EMAIL${typeParam}:${email.address}`);
}
}
if (contact.phones) {
for (const phone of Object.values(contact.phones)) {
const type = contextToType(phone.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`TEL${typeParam}:${phone.number}`);
}
}
if (contact.organizations) {
for (const org of Object.values(contact.organizations)) {
const parts = [org.name || ""];
if (org.units) parts.push(...org.units.map(u => u.name));
lines.push(`ORG:${parts.map(encodeValue).join(";")}`);
}
}
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
const type = contextToType(addr.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
const parts = [
"",
"",
addr.street || "",
addr.locality || "",
addr.region || "",
addr.postcode || "",
addr.country || "",
];
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
}
}
if (contact.notes) {
for (const n of Object.values(contact.notes)) {
lines.push(`NOTE:${encodeValue(n.note)}`);
}
}
if (contact.members) {
for (const memberId of Object.keys(contact.members)) {
if (contact.members[memberId]) {
lines.push(`MEMBER:urn:uuid:${memberId}`);
}
}
}
lines.push("END:VCARD");
return lines.join("\r\n");
}
export function detectDuplicates(
existing: ContactCard[],
incoming: ContactCard[]
): Map<number, string> {
const dupes = new Map<number, string>();
const existingEmails = new Map<string, string>();
for (const c of existing) {
if (c.emails) {
for (const e of Object.values(c.emails)) {
existingEmails.set(e.address.toLowerCase(), c.id);
}
}
}
incoming.forEach((card, idx) => {
if (card.emails) {
for (const e of Object.values(card.emails)) {
const match = existingEmails.get(e.address.toLowerCase());
if (match) {
dupes.set(idx, match);
return;
}
}
}
});
return dupes;
}
+132 -4
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort",
"connection_failed": "Verbindung zum Server fehlgeschlagen",
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"totp_invalid": "Ungültiger Authentifizierungscode. Überprüfen Sie Ihre Authenticator-App."
},
"config_error": {
"title": "Konfigurationsfehler",
"fetch_failed": "Die Anwendungskonfiguration kann nicht geladen werden. Bitte versuchen Sie es später erneut.",
"server_not_configured": "Der E-Mail-Server wurde nicht konfiguriert. Bitte kontaktieren Sie Ihren Administrator."
},
"remove_from_history": "Aus Verlauf entfernen"
"remove_from_history": "Aus Verlauf entfernen",
"totp_toggle": "Ich habe Zwei-Faktor-Authentifizierung",
"totp_label": "Authentifizierungscode",
"totp_placeholder": "000000",
"totp_hide": "Zwei-Faktor-Authentifizierung ausblenden"
},
"sidebar": {
"close": "Schließen",
@@ -59,7 +64,8 @@
"compose": "Verfassen",
"go_back": "Zurück"
},
"clear_search": "Suche löschen"
"clear_search": "Suche löschen",
"vacation_active": "Abwesenheitsnotiz ist aktiv"
},
"email_list": {
"no_emails": "Keine E-Mails",
@@ -313,7 +319,9 @@
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {{error}}",
"identity_delete_failed": "Identität löschen fehlgeschlagen: {{error}}",
"identity_unauthorized": "Sie sind nicht autorisiert, von dieser E-Mail-Adresse zu senden",
"identity_not_found": "Identität nicht gefunden"
"identity_not_found": "Identität nicht gefunden",
"vacation_saved": "Abwesenheitsnotiz-Einstellungen gespeichert",
"vacation_save_failed": "Fehler beim Speichern der Abwesenheitsnotiz-Einstellungen"
},
"date": {
"today": "Heute",
@@ -366,6 +374,7 @@
"privacy": "Datenschutz & Sicherheit",
"account": "Konto",
"identities": "Identitäten",
"vacation": "Abwesenheitsnotiz",
"advanced": "Erweitert"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "Mehr erfahren"
}
},
"vacation": {
"title": "Abwesenheitsnotiz",
"description": "Automatisch auf eingehende E-Mails antworten, während Sie abwesend sind",
"loading": "Abwesenheitseinstellungen werden geladen...",
"not_supported": "Ihr Mailserver unterstützt keine Abwesenheitsantworten.",
"fetch_error": "Fehler beim Laden der Abwesenheitseinstellungen. Bitte versuchen Sie es erneut.",
"status": {
"label": "Abwesenheitsnotiz",
"description": "Senden Sie eine automatische Antwort an Personen, die Ihnen eine E-Mail senden",
"active": "Aktiv",
"inactive": "Inaktiv"
},
"date_range": {
"title": "Zeitraum",
"description": "Optional die automatische Antwort auf einen bestimmten Zeitraum beschränken",
"start": "Startdatum",
"start_description": "Leer lassen für kein Startlimit",
"end": "Enddatum",
"end_description": "Leer lassen für kein Endlimit"
},
"message": {
"title": "Automatische Antwortnachricht",
"description": "Die Nachricht, die als Antwort gesendet wird",
"subject_label": "Betreff",
"subject_description": "Betreffzeile der automatischen Antwort",
"subject_placeholder": "Abwesenheitsnotiz",
"body_label": "Nachrichtentext",
"body_description": "Nur-Text-Nachrichteninhalt",
"body_placeholder": "Vielen Dank für Ihre E-Mail. Ich bin derzeit nicht im Büro und werde nach meiner Rückkehr antworten."
},
"preview": {
"title": "Vorschau",
"show": "Vorschau anzeigen",
"hide": "Vorschau ausblenden"
},
"save": "Änderungen speichern",
"saving": "Speichern...",
"warnings": {
"end_before_start": "Das Enddatum muss nach dem Startdatum liegen",
"start_in_past": "Das Startdatum liegt in der Vergangenheit",
"empty_body": "Der Nachrichtentext ist leer — Empfänger erhalten eine leere Antwort"
}
},
"advanced": {
"title": "Erweitert",
"description": "Erweiterte Optionen und Entwicklereinstellungen",
@@ -753,6 +805,10 @@
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
"back_to_mail": "Zurück zur E-Mail",
"tabs": {
"all": "Alle",
"groups": "Gruppen"
},
"detail": {
"emails": "E-Mail-Adressen",
"phones": "Telefonnummern",
@@ -788,6 +844,55 @@
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"save_failed": "Kontakt konnte nicht gespeichert werden"
},
"groups": {
"create": "Neue Gruppe",
"edit": "Gruppe bearbeiten",
"empty": "Keine Gruppen",
"delete_confirm": "Möchten Sie diese Gruppe wirklich löschen?",
"name_label": "Gruppenname",
"name_placeholder": "z.B. Team, Familie",
"name_required": "Gruppenname ist erforderlich",
"save_failed": "Gruppe konnte nicht gespeichert werden",
"members_label": "Mitglieder",
"search_members": "Kontakte zum Hinzufügen suchen...",
"no_members": "Keine Mitglieder in dieser Gruppe",
"member_count": "{count, plural, =0 {Keine Mitglieder} one {1 Mitglied} other {# Mitglieder}}"
},
"import": {
"title": "Kontakte importieren",
"drop_hint": "Klicken Sie, um eine vCard-Datei auszuwählen",
"file_types": ".vcf- oder .vcard-Dateien",
"no_contacts": "Keine Kontakte in der Datei gefunden",
"parse_error": "vCard-Datei konnte nicht gelesen werden",
"found": "{count, plural, one {1 Kontakt gefunden} other {# Kontakte gefunden}}",
"duplicate": "Duplikat",
"select_all": "Alle auswählen",
"deselect_all": "Alle abwählen",
"selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}",
"import_button": "Importieren",
"importing": "Wird importiert...",
"success": "{count, plural, one {1 Kontakt importiert} other {# Kontakte importiert}}",
"failed": "Import fehlgeschlagen",
"close": "Schließen",
"file_too_large": "Datei ist zu groß (max. 5 MB)"
},
"export": {
"title": "Kontakte exportieren",
"success": "{count, plural, one {1 Kontakt exportiert} other {# Kontakte exportiert}}"
},
"bulk": {
"selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}",
"select_all": "Alle auswählen",
"delete": "Löschen",
"delete_confirm": "{count, plural, one {1 Kontakt} other {# Kontakte}} löschen?",
"deleted": "{count, plural, one {1 Kontakt gelöscht} other {# Kontakte gelöscht}}",
"add_to_group": "Zur Gruppe hinzufügen",
"choose_group": "Gruppe wählen",
"adding_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} hinzufügen",
"added_to_group": "Kontakte zur Gruppe hinzugefügt",
"export": "Exportieren",
"clear": "Auswahl aufheben"
},
"toast": {
"created": "Kontakt erstellt",
"updated": "Kontakt aktualisiert",
@@ -796,5 +901,28 @@
"error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden"
}
},
"advanced_search": {
"title": "Erweiterte Suche",
"from": "Von",
"from_placeholder": "E-Mail oder Name des Absenders",
"to": "An",
"to_placeholder": "E-Mail oder Name des Empfängers",
"subject": "Betreff",
"subject_placeholder": "Betreff enthält...",
"body": "Nachricht",
"has_attachment": "Anhänge",
"date_after": "Nach dem",
"date_before": "Vor dem",
"starred": "Markiert",
"unread": "Ungelesen",
"read": "Gelesen",
"yes": "Ja",
"no": "Nein",
"clear": "Löschen",
"clear_all": "Alle löschen",
"filters_active": "{count} Filter",
"filters_active_plural": "{count} Filter",
"toggle_filters": "Filter"
}
}
+132 -4
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "Invalid email or password",
"connection_failed": "Failed to connect to the server",
"generic": "An error occurred. Please try again."
"generic": "An error occurred. Please try again.",
"totp_invalid": "Invalid authentication code. Please check your authenticator app."
},
"config_error": {
"title": "Configuration Error",
"fetch_failed": "Unable to load application configuration. Please try again later.",
"server_not_configured": "The mail server has not been configured. Please contact your administrator."
},
"remove_from_history": "Remove from history"
"remove_from_history": "Remove from history",
"totp_toggle": "I have two-factor authentication",
"totp_label": "Authentication code",
"totp_placeholder": "000000",
"totp_hide": "Hide two-factor authentication"
},
"sidebar": {
"close": "Close",
@@ -59,7 +64,8 @@
"compose": "Compose",
"go_back": "Go back"
},
"clear_search": "Clear search"
"clear_search": "Clear search",
"vacation_active": "Vacation responder is active"
},
"email_list": {
"no_emails": "No emails",
@@ -313,7 +319,9 @@
"identity_update_failed": "Failed to update identity: {{error}}",
"identity_delete_failed": "Failed to delete identity: {{error}}",
"identity_unauthorized": "You are not authorized to send from this email address",
"identity_not_found": "Identity not found"
"identity_not_found": "Identity not found",
"vacation_saved": "Vacation responder settings saved",
"vacation_save_failed": "Failed to save vacation responder settings"
},
"date": {
"today": "Today",
@@ -366,6 +374,7 @@
"privacy": "Privacy & Security",
"account": "Account",
"identities": "Identities",
"vacation": "Vacation Responder",
"advanced": "Advanced"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "Learn More"
}
},
"vacation": {
"title": "Vacation Responder",
"description": "Automatically reply to incoming emails while you're away",
"loading": "Loading vacation settings...",
"not_supported": "Your mail server does not support vacation responses.",
"fetch_error": "Failed to load vacation settings. Please try again.",
"status": {
"label": "Vacation Responder",
"description": "Send an automatic reply to people who email you",
"active": "Active",
"inactive": "Inactive"
},
"date_range": {
"title": "Date Range",
"description": "Optionally limit the auto-reply to a specific period",
"start": "Start Date",
"start_description": "Leave empty for no start limit",
"end": "End Date",
"end_description": "Leave empty for no end limit"
},
"message": {
"title": "Auto-Reply Message",
"description": "The message that will be sent as a reply",
"subject_label": "Subject",
"subject_description": "Subject line of the auto-reply",
"subject_placeholder": "Out of Office",
"body_label": "Message Body",
"body_description": "Plain text message content",
"body_placeholder": "Thank you for your email. I am currently out of the office and will respond when I return."
},
"preview": {
"title": "Preview",
"show": "Show preview",
"hide": "Hide preview"
},
"save": "Save Changes",
"saving": "Saving...",
"warnings": {
"end_before_start": "End date must be after start date",
"start_in_past": "Start date is in the past",
"empty_body": "Message body is empty — recipients will receive a blank reply"
}
},
"advanced": {
"title": "Advanced",
"description": "Advanced options and developer settings",
@@ -753,6 +805,10 @@
"delete_confirm": "Are you sure you want to delete this contact?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_mail": "Back to mail",
"tabs": {
"all": "All",
"groups": "Groups"
},
"detail": {
"emails": "Email Addresses",
"phones": "Phone Numbers",
@@ -788,6 +844,55 @@
"email_invalid": "Please enter a valid email address",
"save_failed": "Failed to save contact"
},
"groups": {
"create": "New Group",
"edit": "Edit Group",
"empty": "No groups yet",
"delete_confirm": "Are you sure you want to delete this group?",
"name_label": "Group Name",
"name_placeholder": "e.g., Team, Family",
"name_required": "Group name is required",
"save_failed": "Failed to save group",
"members_label": "Members",
"search_members": "Search contacts to add...",
"no_members": "No members in this group",
"member_count": "{count, plural, =0 {No members} one {1 member} other {# members}}"
},
"import": {
"title": "Import Contacts",
"drop_hint": "Click to select a vCard file",
"file_types": ".vcf or .vcard files",
"no_contacts": "No contacts found in file",
"parse_error": "Failed to parse vCard file",
"found": "{count, plural, one {1 contact found} other {# contacts found}}",
"duplicate": "Duplicate",
"select_all": "Select all",
"deselect_all": "Deselect all",
"selected": "{count, plural, one {1 selected} other {# selected}}",
"import_button": "Import",
"importing": "Importing...",
"success": "{count, plural, one {1 contact imported} other {# contacts imported}}",
"failed": "Import failed",
"close": "Close",
"file_too_large": "File is too large (max 5 MB)"
},
"export": {
"title": "Export Contacts",
"success": "{count, plural, one {1 contact exported} other {# contacts exported}}"
},
"bulk": {
"selected": "{count, plural, one {1 selected} other {# selected}}",
"select_all": "Select all",
"delete": "Delete",
"delete_confirm": "Delete {count, plural, one {1 contact} other {# contacts}}?",
"deleted": "{count, plural, one {1 contact deleted} other {# contacts deleted}}",
"add_to_group": "Add to group",
"choose_group": "Choose a group",
"adding_contacts": "Adding {count, plural, one {1 contact} other {# contacts}}",
"added_to_group": "Contacts added to group",
"export": "Export",
"clear": "Clear selection"
},
"toast": {
"created": "Contact created",
"updated": "Contact updated",
@@ -796,5 +901,28 @@
"error_update": "Failed to update contact",
"error_delete": "Failed to delete contact"
}
},
"advanced_search": {
"title": "Advanced Search",
"from": "From",
"from_placeholder": "Sender email or name",
"to": "To",
"to_placeholder": "Recipient email or name",
"subject": "Subject",
"subject_placeholder": "Subject contains...",
"body": "Body",
"has_attachment": "Attachments",
"date_after": "After",
"date_before": "Before",
"starred": "Starred",
"unread": "Unread",
"read": "Read",
"yes": "Yes",
"no": "No",
"clear": "Clear",
"clear_all": "Clear all",
"filters_active": "{count} filter",
"filters_active_plural": "{count} filters",
"toggle_filters": "Filters"
}
}
+132 -4
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "Correo electrónico o contraseña inválidos",
"connection_failed": "No se pudo conectar con el servidor",
"generic": "Ocurrió un error. Por favor, inténtelo de nuevo."
"generic": "Ocurrió un error. Por favor, inténtelo de nuevo.",
"totp_invalid": "Código de autenticación inválido. Verifica tu aplicación de autenticación."
},
"config_error": {
"title": "Error de Configuración",
"fetch_failed": "No se pudo cargar la configuración de la aplicación. Por favor, inténtelo más tarde.",
"server_not_configured": "El servidor de correo no ha sido configurado. Por favor, contacte a su administrador."
},
"remove_from_history": "Eliminar del historial"
"remove_from_history": "Eliminar del historial",
"totp_toggle": "Tengo autenticación de dos factores",
"totp_label": "Código de autenticación",
"totp_placeholder": "000000",
"totp_hide": "Ocultar autenticación de dos factores"
},
"sidebar": {
"close": "Cerrar",
@@ -59,7 +64,8 @@
"compose": "Redactar",
"go_back": "Volver"
},
"clear_search": "Limpiar búsqueda"
"clear_search": "Limpiar búsqueda",
"vacation_active": "Respuesta automática activa"
},
"email_list": {
"no_emails": "Sin correos",
@@ -313,7 +319,9 @@
"identity_update_failed": "Error al actualizar identidad: {{error}}",
"identity_delete_failed": "Error al eliminar identidad: {{error}}",
"identity_unauthorized": "No está autorizado para enviar desde esta dirección de correo",
"identity_not_found": "Identidad no encontrada"
"identity_not_found": "Identidad no encontrada",
"vacation_saved": "Configuración de respuesta automática guardada",
"vacation_save_failed": "Error al guardar la configuración de respuesta automática"
},
"date": {
"today": "Hoy",
@@ -366,6 +374,7 @@
"privacy": "Privacidad y Seguridad",
"account": "Cuenta",
"identities": "Identidades",
"vacation": "Respuesta automática",
"advanced": "Avanzado"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "Más Información"
}
},
"vacation": {
"title": "Respuesta automática",
"description": "Responder automáticamente a los correos entrantes mientras está ausente",
"loading": "Cargando configuración de respuesta automática...",
"not_supported": "Su servidor de correo no admite respuestas automáticas de ausencia.",
"fetch_error": "Error al cargar la configuración de respuesta automática. Inténtelo de nuevo.",
"status": {
"label": "Respuesta automática",
"description": "Enviar una respuesta automática a quienes le envíen un correo",
"active": "Activo",
"inactive": "Inactivo"
},
"date_range": {
"title": "Período",
"description": "Opcionalmente limitar la respuesta automática a un período específico",
"start": "Fecha de inicio",
"start_description": "Dejar vacío para sin límite de inicio",
"end": "Fecha de fin",
"end_description": "Dejar vacío para sin límite de fin"
},
"message": {
"title": "Mensaje de respuesta automática",
"description": "El mensaje que se enviará como respuesta",
"subject_label": "Asunto",
"subject_description": "Línea de asunto de la respuesta automática",
"subject_placeholder": "Fuera de la oficina",
"body_label": "Cuerpo del mensaje",
"body_description": "Contenido del mensaje en texto plano",
"body_placeholder": "Gracias por su correo. Actualmente estoy fuera de la oficina y responderé cuando regrese."
},
"preview": {
"title": "Vista previa",
"show": "Mostrar vista previa",
"hide": "Ocultar vista previa"
},
"save": "Guardar cambios",
"saving": "Guardando...",
"warnings": {
"end_before_start": "La fecha de fin debe ser posterior a la fecha de inicio",
"start_in_past": "La fecha de inicio está en el pasado",
"empty_body": "El cuerpo del mensaje está vacío — los destinatarios recibirán una respuesta en blanco"
}
},
"advanced": {
"title": "Avanzado",
"description": "Opciones avanzadas y configuración de desarrollador",
@@ -753,6 +805,10 @@
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
"back_to_mail": "Volver al correo",
"tabs": {
"all": "Todos",
"groups": "Grupos"
},
"detail": {
"emails": "Direcciones de correo",
"phones": "Números de teléfono",
@@ -788,6 +844,55 @@
"email_invalid": "Introduce una dirección de correo válida",
"save_failed": "Error al guardar el contacto"
},
"groups": {
"create": "Nuevo grupo",
"edit": "Editar grupo",
"empty": "No hay grupos",
"delete_confirm": "¿Estás seguro de que quieres eliminar este grupo?",
"name_label": "Nombre del grupo",
"name_placeholder": "ej. Equipo, Familia",
"name_required": "El nombre del grupo es obligatorio",
"save_failed": "Error al guardar el grupo",
"members_label": "Miembros",
"search_members": "Buscar contactos para agregar...",
"no_members": "No hay miembros en este grupo",
"member_count": "{count, plural, =0 {Sin miembros} one {1 miembro} other {# miembros}}"
},
"import": {
"title": "Importar contactos",
"drop_hint": "Haz clic para seleccionar un archivo vCard",
"file_types": "Archivos .vcf o .vcard",
"no_contacts": "No se encontraron contactos en el archivo",
"parse_error": "Error al analizar el archivo vCard",
"found": "{count, plural, one {1 contacto encontrado} other {# contactos encontrados}}",
"duplicate": "Duplicado",
"select_all": "Seleccionar todo",
"deselect_all": "Deseleccionar todo",
"selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}",
"import_button": "Importar",
"importing": "Importando...",
"success": "{count, plural, one {1 contacto importado} other {# contactos importados}}",
"failed": "Error en la importación",
"close": "Cerrar",
"file_too_large": "El archivo es demasiado grande (máx. 5 MB)"
},
"export": {
"title": "Exportar contactos",
"success": "{count, plural, one {1 contacto exportado} other {# contactos exportados}}"
},
"bulk": {
"selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}",
"select_all": "Seleccionar todo",
"delete": "Eliminar",
"delete_confirm": "¿Eliminar {count, plural, one {1 contacto} other {# contactos}}?",
"deleted": "{count, plural, one {1 contacto eliminado} other {# contactos eliminados}}",
"add_to_group": "Agregar al grupo",
"choose_group": "Elegir un grupo",
"adding_contacts": "Agregando {count, plural, one {1 contacto} other {# contactos}}",
"added_to_group": "Contactos agregados al grupo",
"export": "Exportar",
"clear": "Limpiar selección"
},
"toast": {
"created": "Contacto creado",
"updated": "Contacto actualizado",
@@ -796,5 +901,28 @@
"error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto"
}
},
"advanced_search": {
"title": "Búsqueda avanzada",
"from": "De",
"from_placeholder": "Email o nombre del remitente",
"to": "Para",
"to_placeholder": "Email o nombre del destinatario",
"subject": "Asunto",
"subject_placeholder": "El asunto contiene...",
"body": "Cuerpo",
"has_attachment": "Adjuntos",
"date_after": "Después de",
"date_before": "Antes de",
"starred": "Destacados",
"unread": "No leídos",
"read": "Leídos",
"yes": "Sí",
"no": "No",
"clear": "Limpiar",
"clear_all": "Limpiar todo",
"filters_active": "{count} filtro",
"filters_active_plural": "{count} filtros",
"toggle_filters": "Filtros"
}
}
+132 -4
View File
@@ -11,14 +11,19 @@
"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."
"generic": "Une erreur s'est produite. Veuillez réessayer.",
"totp_invalid": "Code d'authentification invalide. Vérifiez votre application d'authentification."
},
"config_error": {
"title": "Erreur de configuration",
"fetch_failed": "Impossible de charger la configuration de l'application. Veuillez réessayer plus tard.",
"server_not_configured": "Le serveur de messagerie n'a pas été configuré. Veuillez contacter votre administrateur."
},
"remove_from_history": "Supprimer de l'historique"
"remove_from_history": "Supprimer de l'historique",
"totp_toggle": "J'ai l'authentification à deux facteurs",
"totp_label": "Code d'authentification",
"totp_placeholder": "000000",
"totp_hide": "Masquer l'authentification à deux facteurs"
},
"sidebar": {
"close": "Fermer",
@@ -59,7 +64,8 @@
"compose": "Composer",
"go_back": "Retour"
},
"clear_search": "Effacer la recherche"
"clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé"
},
"email_list": {
"no_emails": "Aucun email",
@@ -313,7 +319,9 @@
"identity_update_failed": "Échec de la mise à jour de l'identité: {{error}}",
"identity_delete_failed": "Échec de la suppression de l'identité: {{error}}",
"identity_unauthorized": "Vous n'êtes pas autorisé à envoyer depuis cette adresse email",
"identity_not_found": "Identité introuvable"
"identity_not_found": "Identité introuvable",
"vacation_saved": "Paramètres du répondeur d'absence enregistrés",
"vacation_save_failed": "Échec de l'enregistrement des paramètres du répondeur d'absence"
},
"date": {
"today": "Aujourd'hui",
@@ -366,6 +374,7 @@
"privacy": "Confidentialité et sécurité",
"account": "Compte",
"identities": "Identités",
"vacation": "Répondeur d'absence",
"advanced": "Avancé"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "En savoir plus"
}
},
"vacation": {
"title": "Répondeur d'absence",
"description": "Répondre automatiquement aux emails entrants pendant votre absence",
"loading": "Chargement des paramètres d'absence...",
"not_supported": "Votre serveur de messagerie ne prend pas en charge les réponses d'absence.",
"fetch_error": "Échec du chargement des paramètres d'absence. Veuillez réessayer.",
"status": {
"label": "Répondeur d'absence",
"description": "Envoyer une réponse automatique aux personnes qui vous envoient un email",
"active": "Actif",
"inactive": "Inactif"
},
"date_range": {
"title": "Période",
"description": "Limiter optionnellement la réponse automatique à une période spécifique",
"start": "Date de début",
"start_description": "Laisser vide pour aucune limite de début",
"end": "Date de fin",
"end_description": "Laisser vide pour aucune limite de fin"
},
"message": {
"title": "Message de réponse automatique",
"description": "Le message qui sera envoyé en réponse",
"subject_label": "Objet",
"subject_description": "Ligne d'objet de la réponse automatique",
"subject_placeholder": "Absence du bureau",
"body_label": "Corps du message",
"body_description": "Contenu du message en texte brut",
"body_placeholder": "Merci pour votre email. Je suis actuellement absent du bureau et vous répondrai à mon retour."
},
"preview": {
"title": "Aperçu",
"show": "Afficher l'aperçu",
"hide": "Masquer l'aperçu"
},
"save": "Enregistrer les modifications",
"saving": "Enregistrement...",
"warnings": {
"end_before_start": "La date de fin doit être postérieure à la date de début",
"start_in_past": "La date de début est dans le passé",
"empty_body": "Le corps du message est vide — les destinataires recevront une réponse vide"
}
},
"advanced": {
"title": "Avancé",
"description": "Options avancées et paramètres développeur",
@@ -753,6 +805,10 @@
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
"back_to_mail": "Retour aux e-mails",
"tabs": {
"all": "Tous",
"groups": "Groupes"
},
"detail": {
"emails": "Adresses e-mail",
"phones": "Numéros de téléphone",
@@ -788,6 +844,55 @@
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"save_failed": "Échec de l'enregistrement du contact"
},
"groups": {
"create": "Nouveau groupe",
"edit": "Modifier le groupe",
"empty": "Aucun groupe",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce groupe ?",
"name_label": "Nom du groupe",
"name_placeholder": "ex. Équipe, Famille",
"name_required": "Le nom du groupe est requis",
"save_failed": "Échec de l'enregistrement du groupe",
"members_label": "Membres",
"search_members": "Rechercher des contacts à ajouter...",
"no_members": "Aucun membre dans ce groupe",
"member_count": "{count, plural, =0 {Aucun membre} one {1 membre} other {# membres}}"
},
"import": {
"title": "Importer des contacts",
"drop_hint": "Cliquez pour sélectionner un fichier vCard",
"file_types": "Fichiers .vcf ou .vcard",
"no_contacts": "Aucun contact trouvé dans le fichier",
"parse_error": "Échec de l'analyse du fichier vCard",
"found": "{count, plural, one {1 contact trouvé} other {# contacts trouvés}}",
"duplicate": "Doublon",
"select_all": "Tout sélectionner",
"deselect_all": "Tout désélectionner",
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
"import_button": "Importer",
"importing": "Importation...",
"success": "{count, plural, one {1 contact importé} other {# contacts importés}}",
"failed": "Échec de l'importation",
"close": "Fermer",
"file_too_large": "Fichier trop volumineux (max 5 Mo)"
},
"export": {
"title": "Exporter les contacts",
"success": "{count, plural, one {1 contact exporté} other {# contacts exportés}}"
},
"bulk": {
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
"select_all": "Tout sélectionner",
"delete": "Supprimer",
"delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?",
"deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}",
"add_to_group": "Ajouter au groupe",
"choose_group": "Choisir un groupe",
"adding_contacts": "Ajout de {count, plural, one {1 contact} other {# contacts}}",
"added_to_group": "Contacts ajoutés au groupe",
"export": "Exporter",
"clear": "Effacer la sélection"
},
"toast": {
"created": "Contact créé",
"updated": "Contact mis à jour",
@@ -796,5 +901,28 @@
"error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact"
}
},
"advanced_search": {
"title": "Recherche avancée",
"from": "De",
"from_placeholder": "Email ou nom de l'expéditeur",
"to": "À",
"to_placeholder": "Email ou nom du destinataire",
"subject": "Objet",
"subject_placeholder": "L'objet contient...",
"body": "Corps",
"has_attachment": "Pièces jointes",
"date_after": "Après le",
"date_before": "Avant le",
"starred": "Suivis",
"unread": "Non lus",
"read": "Lus",
"yes": "Oui",
"no": "Non",
"clear": "Effacer",
"clear_all": "Tout effacer",
"filters_active": "{count} filtre",
"filters_active_plural": "{count} filtres",
"toggle_filters": "Filtres"
}
}
+132 -4
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "Email o password non valida",
"connection_failed": "Impossibile connettersi al server",
"generic": "Si è verificato un errore. Riprova."
"generic": "Si è verificato un errore. Riprova.",
"totp_invalid": "Codice di autenticazione non valido. Controlla la tua app di autenticazione."
},
"config_error": {
"title": "Errore di configurazione",
"fetch_failed": "Impossibile caricare la configurazione dell'applicazione. Riprovare più tardi.",
"server_not_configured": "Il server di posta non è stato configurato. Contattare l'amministratore."
},
"remove_from_history": "Rimuovi dalla cronologia"
"remove_from_history": "Rimuovi dalla cronologia",
"totp_toggle": "Ho l'autenticazione a due fattori",
"totp_label": "Codice di autenticazione",
"totp_placeholder": "000000",
"totp_hide": "Nascondi autenticazione a due fattori"
},
"sidebar": {
"close": "Chiudi",
@@ -59,7 +64,8 @@
"compose": "Scrivi",
"go_back": "Indietro"
},
"clear_search": "Cancella ricerca"
"clear_search": "Cancella ricerca",
"vacation_active": "Risponditore automatico attivo"
},
"email_list": {
"no_emails": "Nessun messaggio",
@@ -313,7 +319,9 @@
"identity_update_failed": "Impossibile aggiornare l'identità: {{error}}",
"identity_delete_failed": "Impossibile eliminare l'identità: {{error}}",
"identity_unauthorized": "Non sei autorizzato a inviare da questo indirizzo email",
"identity_not_found": "Identità non trovata"
"identity_not_found": "Identità non trovata",
"vacation_saved": "Impostazioni del risponditore automatico salvate",
"vacation_save_failed": "Impossibile salvare le impostazioni del risponditore automatico"
},
"date": {
"today": "Oggi",
@@ -366,6 +374,7 @@
"privacy": "Privacy e sicurezza",
"account": "Account",
"identities": "Identità",
"vacation": "Risponditore automatico",
"advanced": "Avanzate"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "Scopri di più"
}
},
"vacation": {
"title": "Risponditore automatico",
"description": "Rispondi automaticamente alle email in arrivo durante la tua assenza",
"loading": "Caricamento impostazioni di risposta automatica...",
"not_supported": "Il server di posta non supporta le risposte automatiche di assenza.",
"fetch_error": "Impossibile caricare le impostazioni di risposta automatica. Riprova.",
"status": {
"label": "Risponditore automatico",
"description": "Invia una risposta automatica a chi ti scrive",
"active": "Attivo",
"inactive": "Inattivo"
},
"date_range": {
"title": "Periodo",
"description": "Limita opzionalmente la risposta automatica a un periodo specifico",
"start": "Data di inizio",
"start_description": "Lascia vuoto per nessun limite di inizio",
"end": "Data di fine",
"end_description": "Lascia vuoto per nessun limite di fine"
},
"message": {
"title": "Messaggio di risposta automatica",
"description": "Il messaggio che verrà inviato come risposta",
"subject_label": "Oggetto",
"subject_description": "Oggetto della risposta automatica",
"subject_placeholder": "Fuori ufficio",
"body_label": "Corpo del messaggio",
"body_description": "Contenuto del messaggio in testo semplice",
"body_placeholder": "Grazie per la tua email. Sono attualmente fuori ufficio e risponderò al mio ritorno."
},
"preview": {
"title": "Anteprima",
"show": "Mostra anteprima",
"hide": "Nascondi anteprima"
},
"save": "Salva modifiche",
"saving": "Salvataggio...",
"warnings": {
"end_before_start": "La data di fine deve essere successiva alla data di inizio",
"start_in_past": "La data di inizio è nel passato",
"empty_body": "Il corpo del messaggio è vuoto — i destinatari riceveranno una risposta vuota"
}
},
"advanced": {
"title": "Avanzate",
"description": "Opzioni avanzate e impostazioni per sviluppatori",
@@ -753,6 +805,10 @@
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
"back_to_mail": "Torna alla posta",
"tabs": {
"all": "Tutti",
"groups": "Gruppi"
},
"detail": {
"emails": "Indirizzi email",
"phones": "Numeri di telefono",
@@ -788,6 +844,55 @@
"email_invalid": "Inserisci un indirizzo email valido",
"save_failed": "Impossibile salvare il contatto"
},
"groups": {
"create": "Nuovo gruppo",
"edit": "Modifica gruppo",
"empty": "Nessun gruppo",
"delete_confirm": "Sei sicuro di voler eliminare questo gruppo?",
"name_label": "Nome del gruppo",
"name_placeholder": "es. Team, Famiglia",
"name_required": "Il nome del gruppo è obbligatorio",
"save_failed": "Impossibile salvare il gruppo",
"members_label": "Membri",
"search_members": "Cerca contatti da aggiungere...",
"no_members": "Nessun membro in questo gruppo",
"member_count": "{count, plural, =0 {Nessun membro} one {1 membro} other {# membri}}"
},
"import": {
"title": "Importa contatti",
"drop_hint": "Clicca per selezionare un file vCard",
"file_types": "File .vcf o .vcard",
"no_contacts": "Nessun contatto trovato nel file",
"parse_error": "Impossibile analizzare il file vCard",
"found": "{count, plural, one {1 contatto trovato} other {# contatti trovati}}",
"duplicate": "Duplicato",
"select_all": "Seleziona tutto",
"deselect_all": "Deseleziona tutto",
"selected": "{count, plural, one {1 selezionato} other {# selezionati}}",
"import_button": "Importa",
"importing": "Importazione...",
"success": "{count, plural, one {1 contatto importato} other {# contatti importati}}",
"failed": "Importazione fallita",
"close": "Chiudi",
"file_too_large": "Il file è troppo grande (max 5 MB)"
},
"export": {
"title": "Esporta contatti",
"success": "{count, plural, one {1 contatto esportato} other {# contatti esportati}}"
},
"bulk": {
"selected": "{count, plural, one {1 selezionato} other {# selezionati}}",
"select_all": "Seleziona tutto",
"delete": "Elimina",
"delete_confirm": "Eliminare {count, plural, one {1 contatto} other {# contatti}}?",
"deleted": "{count, plural, one {1 contatto eliminato} other {# contatti eliminati}}",
"add_to_group": "Aggiungi al gruppo",
"choose_group": "Scegli un gruppo",
"adding_contacts": "Aggiunta di {count, plural, one {1 contatto} other {# contatti}}",
"added_to_group": "Contatti aggiunti al gruppo",
"export": "Esporta",
"clear": "Cancella selezione"
},
"toast": {
"created": "Contatto creato",
"updated": "Contatto aggiornato",
@@ -796,5 +901,28 @@
"error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare il contatto"
}
},
"advanced_search": {
"title": "Ricerca avanzata",
"from": "Da",
"from_placeholder": "Email o nome del mittente",
"to": "A",
"to_placeholder": "Email o nome del destinatario",
"subject": "Oggetto",
"subject_placeholder": "L'oggetto contiene...",
"body": "Corpo",
"has_attachment": "Allegati",
"date_after": "Dopo il",
"date_before": "Prima del",
"starred": "Preferiti",
"unread": "Non letti",
"read": "Letti",
"yes": "Sì",
"no": "No",
"clear": "Cancella",
"clear_all": "Cancella tutto",
"filters_active": "{count} filtro",
"filters_active_plural": "{count} filtri",
"toggle_filters": "Filtri"
}
}
+132 -4
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "メールアドレスまたはパスワードが無効です",
"connection_failed": "サーバーへの接続に失敗しました",
"generic": "エラーが発生しました。もう一度お試しください。"
"generic": "エラーが発生しました。もう一度お試しください。",
"totp_invalid": "認証コードが無効です。認証アプリを確認してください。"
},
"config_error": {
"title": "設定エラー",
"fetch_failed": "アプリケーション設定を読み込めません。後でもう一度お試しください。",
"server_not_configured": "メールサーバーが設定されていません。管理者にお問い合わせください。"
},
"remove_from_history": "履歴から削除"
"remove_from_history": "履歴から削除",
"totp_toggle": "二要素認証を使用",
"totp_label": "認証コード",
"totp_placeholder": "000000",
"totp_hide": "二要素認証を非表示"
},
"sidebar": {
"close": "閉じる",
@@ -59,7 +64,8 @@
"compose": "作成",
"go_back": "戻る"
},
"clear_search": "検索をクリア"
"clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です"
},
"email_list": {
"no_emails": "メールがありません",
@@ -313,7 +319,9 @@
"identity_update_failed": "送信者情報の更新に失敗しました: {{error}}",
"identity_delete_failed": "送信者情報の削除に失敗しました: {{error}}",
"identity_unauthorized": "このメールアドレスからの送信は許可されていません",
"identity_not_found": "送信者情報が見つかりません"
"identity_not_found": "送信者情報が見つかりません",
"vacation_saved": "不在応答の設定を保存しました",
"vacation_save_failed": "不在応答の設定の保存に失敗しました"
},
"date": {
"today": "今日",
@@ -366,6 +374,7 @@
"privacy": "プライバシーとセキュリティ",
"account": "アカウント",
"identities": "送信者情報",
"vacation": "不在応答",
"advanced": "詳細設定"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "詳細"
}
},
"vacation": {
"title": "不在応答",
"description": "不在時に受信メールへ自動返信します",
"loading": "不在応答設定を読み込み中...",
"not_supported": "メールサーバーは不在応答をサポートしていません。",
"fetch_error": "不在応答設定の読み込みに失敗しました。もう一度お試しください。",
"status": {
"label": "不在応答",
"description": "メールを送信してきた方に自動返信を送信します",
"active": "有効",
"inactive": "無効"
},
"date_range": {
"title": "期間",
"description": "オプションで自動返信を特定の期間に限定します",
"start": "開始日",
"start_description": "空白のままにすると開始制限なし",
"end": "終了日",
"end_description": "空白のままにすると終了制限なし"
},
"message": {
"title": "自動返信メッセージ",
"description": "返信として送信されるメッセージ",
"subject_label": "件名",
"subject_description": "自動返信の件名",
"subject_placeholder": "不在通知",
"body_label": "メッセージ本文",
"body_description": "テキスト形式のメッセージ内容",
"body_placeholder": "メールをいただきありがとうございます。現在不在にしており、戻り次第ご返信いたします。"
},
"preview": {
"title": "プレビュー",
"show": "プレビューを表示",
"hide": "プレビューを非表示"
},
"save": "変更を保存",
"saving": "保存中...",
"warnings": {
"end_before_start": "終了日は開始日より後に設定してください",
"start_in_past": "開始日が過去の日付です",
"empty_body": "メッセージ本文が空です — 受信者には空の返信が届きます"
}
},
"advanced": {
"title": "詳細設定",
"description": "詳細オプションと開発者設定",
@@ -753,6 +805,10 @@
"delete_confirm": "この連絡先を削除してもよろしいですか?",
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
"back_to_mail": "メールに戻る",
"tabs": {
"all": "すべて",
"groups": "グループ"
},
"detail": {
"emails": "メールアドレス",
"phones": "電話番号",
@@ -788,6 +844,55 @@
"email_invalid": "有効なメールアドレスを入力してください",
"save_failed": "連絡先の保存に失敗しました"
},
"groups": {
"create": "新しいグループ",
"edit": "グループを編集",
"empty": "グループがありません",
"delete_confirm": "このグループを削除してもよろしいですか?",
"name_label": "グループ名",
"name_placeholder": "例:チーム、家族",
"name_required": "グループ名は必須です",
"save_failed": "グループの保存に失敗しました",
"members_label": "メンバー",
"search_members": "追加する連絡先を検索...",
"no_members": "このグループにメンバーがいません",
"member_count": "{count, plural, =0 {メンバーなし} other {#人のメンバー}}"
},
"import": {
"title": "連絡先をインポート",
"drop_hint": "vCardファイルを選択してください",
"file_types": ".vcf または .vcard ファイル",
"no_contacts": "ファイルに連絡先が見つかりません",
"parse_error": "vCardファイルの解析に失敗しました",
"found": "{count, plural, other {#件の連絡先が見つかりました}}",
"duplicate": "重複",
"select_all": "すべて選択",
"deselect_all": "すべて解除",
"selected": "{count, plural, other {#件選択中}}",
"import_button": "インポート",
"importing": "インポート中...",
"success": "{count, plural, other {#件の連絡先をインポートしました}}",
"failed": "インポートに失敗しました",
"close": "閉じる",
"file_too_large": "ファイルが大きすぎます(最大5 MB)"
},
"export": {
"title": "連絡先をエクスポート",
"success": "{count, plural, other {#件の連絡先をエクスポートしました}}"
},
"bulk": {
"selected": "{count, plural, other {#件選択中}}",
"select_all": "すべて選択",
"delete": "削除",
"delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?",
"deleted": "{count, plural, other {#件の連絡先を削除しました}}",
"add_to_group": "グループに追加",
"choose_group": "グループを選択",
"adding_contacts": "{count, plural, other {#件の連絡先}}を追加",
"added_to_group": "連絡先をグループに追加しました",
"export": "エクスポート",
"clear": "選択を解除"
},
"toast": {
"created": "連絡先を作成しました",
"updated": "連絡先を更新しました",
@@ -796,5 +901,28 @@
"error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました"
}
},
"advanced_search": {
"title": "詳細検索",
"from": "差出人",
"from_placeholder": "差出人のメールまたは名前",
"to": "宛先",
"to_placeholder": "宛先のメールまたは名前",
"subject": "件名",
"subject_placeholder": "件名に含まれる...",
"body": "本文",
"has_attachment": "添付ファイル",
"date_after": "以降",
"date_before": "以前",
"starred": "スター付き",
"unread": "未読",
"read": "既読",
"yes": "はい",
"no": "いいえ",
"clear": "クリア",
"clear_all": "すべてクリア",
"filters_active": "{count}件のフィルター",
"filters_active_plural": "{count}件のフィルター",
"toggle_filters": "フィルター"
}
}
+132 -4
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "Ongeldig e-mailadres of wachtwoord",
"connection_failed": "Kan geen verbinding maken met de server",
"generic": "Er is een fout opgetreden. Probeer het opnieuw."
"generic": "Er is een fout opgetreden. Probeer het opnieuw.",
"totp_invalid": "Ongeldige authenticatiecode. Controleer uw authenticator-app."
},
"config_error": {
"title": "Configuratiefout",
"fetch_failed": "Kan de applicatieconfiguratie niet laden. Probeer het later opnieuw.",
"server_not_configured": "De mailserver is niet geconfigureerd. Neem contact op met je beheerder."
},
"remove_from_history": "Verwijder uit geschiedenis"
"remove_from_history": "Verwijder uit geschiedenis",
"totp_toggle": "Ik heb tweefactorauthenticatie",
"totp_label": "Authenticatiecode",
"totp_placeholder": "000000",
"totp_hide": "Tweefactorauthenticatie verbergen"
},
"sidebar": {
"close": "Sluiten",
@@ -59,7 +64,8 @@
"compose": "Opstellen",
"go_back": "Terug"
},
"clear_search": "Zoekopdracht wissen"
"clear_search": "Zoekopdracht wissen",
"vacation_active": "Afwezigheidsmelder is actief"
},
"email_list": {
"no_emails": "Geen e-mails",
@@ -313,7 +319,9 @@
"identity_update_failed": "Kan identiteit niet bijwerken: {{error}}",
"identity_delete_failed": "Kan identiteit niet verwijderen: {{error}}",
"identity_unauthorized": "Je bent niet geautoriseerd om vanaf dit e-mailadres te verzenden",
"identity_not_found": "Identiteit niet gevonden"
"identity_not_found": "Identiteit niet gevonden",
"vacation_saved": "Afwezigheidsinstellingen opgeslagen",
"vacation_save_failed": "Kan afwezigheidsinstellingen niet opslaan"
},
"date": {
"today": "Vandaag",
@@ -366,6 +374,7 @@
"privacy": "Privacy & Beveiliging",
"account": "Account",
"identities": "Identiteiten",
"vacation": "Afwezigheidsmelder",
"advanced": "Geavanceerd"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "Meer informatie"
}
},
"vacation": {
"title": "Afwezigheidsmelder",
"description": "Automatisch antwoorden op inkomende e-mails terwijl je afwezig bent",
"loading": "Afwezigheidsinstellingen laden...",
"not_supported": "Je mailserver ondersteunt geen afwezigheidsantwoorden.",
"fetch_error": "Kan afwezigheidsinstellingen niet laden. Probeer het opnieuw.",
"status": {
"label": "Afwezigheidsmelder",
"description": "Stuur een automatisch antwoord naar mensen die je mailen",
"active": "Actief",
"inactive": "Inactief"
},
"date_range": {
"title": "Periode",
"description": "Beperk optioneel het automatische antwoord tot een specifieke periode",
"start": "Startdatum",
"start_description": "Leeg laten voor geen startlimiet",
"end": "Einddatum",
"end_description": "Leeg laten voor geen eindlimiet"
},
"message": {
"title": "Automatisch antwoordbericht",
"description": "Het bericht dat als antwoord wordt verzonden",
"subject_label": "Onderwerp",
"subject_description": "Onderwerpregel van het automatische antwoord",
"subject_placeholder": "Afwezigheidsbericht",
"body_label": "Berichttekst",
"body_description": "Inhoud van het bericht in platte tekst",
"body_placeholder": "Bedankt voor je e-mail. Ik ben momenteel niet aanwezig en zal reageren bij terugkomst."
},
"preview": {
"title": "Voorbeeld",
"show": "Voorbeeld tonen",
"hide": "Voorbeeld verbergen"
},
"save": "Wijzigingen opslaan",
"saving": "Opslaan...",
"warnings": {
"end_before_start": "De einddatum moet na de startdatum liggen",
"start_in_past": "De startdatum ligt in het verleden",
"empty_body": "De berichttekst is leeg — ontvangers krijgen een leeg antwoord"
}
},
"advanced": {
"title": "Geavanceerd",
"description": "Geavanceerde opties en ontwikkelaarsinstellingen",
@@ -753,6 +805,10 @@
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
"back_to_mail": "Terug naar e-mail",
"tabs": {
"all": "Alle",
"groups": "Groepen"
},
"detail": {
"emails": "E-mailadressen",
"phones": "Telefoonnummers",
@@ -788,6 +844,55 @@
"email_invalid": "Voer een geldig e-mailadres in",
"save_failed": "Kon contact niet opslaan"
},
"groups": {
"create": "Nieuwe groep",
"edit": "Groep bewerken",
"empty": "Geen groepen",
"delete_confirm": "Weet u zeker dat u deze groep wilt verwijderen?",
"name_label": "Groepsnaam",
"name_placeholder": "bijv. Team, Familie",
"name_required": "Groepsnaam is vereist",
"save_failed": "Kon groep niet opslaan",
"members_label": "Leden",
"search_members": "Contacten zoeken om toe te voegen...",
"no_members": "Geen leden in deze groep",
"member_count": "{count, plural, =0 {Geen leden} one {1 lid} other {# leden}}"
},
"import": {
"title": "Contacten importeren",
"drop_hint": "Klik om een vCard-bestand te selecteren",
"file_types": ".vcf- of .vcard-bestanden",
"no_contacts": "Geen contacten gevonden in bestand",
"parse_error": "Kon vCard-bestand niet lezen",
"found": "{count, plural, one {1 contact gevonden} other {# contacten gevonden}}",
"duplicate": "Duplicaat",
"select_all": "Alles selecteren",
"deselect_all": "Alles deselecteren",
"selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}",
"import_button": "Importeren",
"importing": "Importeren...",
"success": "{count, plural, one {1 contact geïmporteerd} other {# contacten geïmporteerd}}",
"failed": "Import mislukt",
"close": "Sluiten",
"file_too_large": "Bestand is te groot (max 5 MB)"
},
"export": {
"title": "Contacten exporteren",
"success": "{count, plural, one {1 contact geëxporteerd} other {# contacten geëxporteerd}}"
},
"bulk": {
"selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}",
"select_all": "Alles selecteren",
"delete": "Verwijderen",
"delete_confirm": "{count, plural, one {1 contact} other {# contacten}} verwijderen?",
"deleted": "{count, plural, one {1 contact verwijderd} other {# contacten verwijderd}}",
"add_to_group": "Aan groep toevoegen",
"choose_group": "Kies een groep",
"adding_contacts": "{count, plural, one {1 contact} other {# contacten}} toevoegen",
"added_to_group": "Contacten aan groep toegevoegd",
"export": "Exporteren",
"clear": "Selectie wissen"
},
"toast": {
"created": "Contact aangemaakt",
"updated": "Contact bijgewerkt",
@@ -796,5 +901,28 @@
"error_update": "Kon contact niet bijwerken",
"error_delete": "Kon contact niet verwijderen"
}
},
"advanced_search": {
"title": "Geavanceerd zoeken",
"from": "Van",
"from_placeholder": "E-mail of naam afzender",
"to": "Aan",
"to_placeholder": "E-mail of naam ontvanger",
"subject": "Onderwerp",
"subject_placeholder": "Onderwerp bevat...",
"body": "Inhoud",
"has_attachment": "Bijlagen",
"date_after": "Na",
"date_before": "Voor",
"starred": "Met ster",
"unread": "Ongelezen",
"read": "Gelezen",
"yes": "Ja",
"no": "Nee",
"clear": "Wissen",
"clear_all": "Alles wissen",
"filters_active": "{count} filter",
"filters_active_plural": "{count} filters",
"toggle_filters": "Filters"
}
}
+132 -4
View File
@@ -11,14 +11,19 @@
"error": {
"invalid_credentials": "E-mail ou senha inválidos",
"connection_failed": "Falha ao conectar com o servidor",
"generic": "Ocorreu um erro. Por favor, tente novamente."
"generic": "Ocorreu um erro. Por favor, tente novamente.",
"totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação."
},
"config_error": {
"title": "Erro de Configuração",
"fetch_failed": "Não foi possível carregar a configuração do aplicativo. Por favor, tente novamente mais tarde.",
"server_not_configured": "O servidor de e-mail não foi configurado. Por favor, contate seu administrador."
},
"remove_from_history": "Remover do histórico"
"remove_from_history": "Remover do histórico",
"totp_toggle": "Tenho autenticação de dois fatores",
"totp_label": "Código de autenticação",
"totp_placeholder": "000000",
"totp_hide": "Ocultar autenticação de dois fatores"
},
"sidebar": {
"close": "Fechar",
@@ -59,7 +64,8 @@
"compose": "Escrever",
"go_back": "Voltar"
},
"clear_search": "Limpar busca"
"clear_search": "Limpar busca",
"vacation_active": "Resposta automática ativa"
},
"email_list": {
"no_emails": "Nenhum e-mail",
@@ -313,7 +319,9 @@
"identity_update_failed": "Falha ao atualizar identidade: {{error}}",
"identity_delete_failed": "Falha ao excluir identidade: {{error}}",
"identity_unauthorized": "Você não está autorizado a enviar deste endereço de e-mail",
"identity_not_found": "Identidade não encontrada"
"identity_not_found": "Identidade não encontrada",
"vacation_saved": "Configurações de resposta automática salvas",
"vacation_save_failed": "Falha ao salvar configurações de resposta automática"
},
"date": {
"today": "Hoje",
@@ -366,6 +374,7 @@
"privacy": "Privacidade e Segurança",
"account": "Conta",
"identities": "Identidades",
"vacation": "Resposta automática",
"advanced": "Avançado"
},
"appearance": {
@@ -568,6 +577,49 @@
"learn_more": "Saiba Mais"
}
},
"vacation": {
"title": "Resposta automática",
"description": "Responder automaticamente a e-mails recebidos enquanto estiver ausente",
"loading": "Carregando configurações de resposta automática...",
"not_supported": "Seu servidor de e-mail não suporta respostas automáticas de ausência.",
"fetch_error": "Falha ao carregar as configurações de resposta automática. Tente novamente.",
"status": {
"label": "Resposta automática",
"description": "Enviar uma resposta automática para quem lhe enviar um e-mail",
"active": "Ativo",
"inactive": "Inativo"
},
"date_range": {
"title": "Período",
"description": "Opcionalmente limitar a resposta automática a um período específico",
"start": "Data de início",
"start_description": "Deixe vazio para sem limite de início",
"end": "Data de fim",
"end_description": "Deixe vazio para sem limite de fim"
},
"message": {
"title": "Mensagem de resposta automática",
"description": "A mensagem que será enviada como resposta",
"subject_label": "Assunto",
"subject_description": "Linha de assunto da resposta automática",
"subject_placeholder": "Fora do escritório",
"body_label": "Corpo da mensagem",
"body_description": "Conteúdo da mensagem em texto simples",
"body_placeholder": "Obrigado pelo seu e-mail. Estou atualmente fora do escritório e responderei quando retornar."
},
"preview": {
"title": "Pré-visualização",
"show": "Mostrar pré-visualização",
"hide": "Ocultar pré-visualização"
},
"save": "Salvar alterações",
"saving": "Salvando...",
"warnings": {
"end_before_start": "A data de fim deve ser posterior à data de início",
"start_in_past": "A data de início está no passado",
"empty_body": "O corpo da mensagem está vazio — os destinatários receberão uma resposta em branco"
}
},
"advanced": {
"title": "Avançado",
"description": "Opções avançadas e configurações de desenvolvedor",
@@ -753,6 +805,10 @@
"delete_confirm": "Tem certeza de que deseja excluir este contato?",
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
"back_to_mail": "Voltar ao e-mail",
"tabs": {
"all": "Todos",
"groups": "Grupos"
},
"detail": {
"emails": "Endereços de e-mail",
"phones": "Números de telefone",
@@ -788,6 +844,55 @@
"email_invalid": "Por favor, insira um endereço de e-mail válido",
"save_failed": "Falha ao salvar contato"
},
"groups": {
"create": "Novo grupo",
"edit": "Editar grupo",
"empty": "Nenhum grupo",
"delete_confirm": "Tem certeza de que deseja excluir este grupo?",
"name_label": "Nome do grupo",
"name_placeholder": "ex. Equipe, Família",
"name_required": "O nome do grupo é obrigatório",
"save_failed": "Falha ao salvar grupo",
"members_label": "Membros",
"search_members": "Pesquisar contatos para adicionar...",
"no_members": "Nenhum membro neste grupo",
"member_count": "{count, plural, =0 {Nenhum membro} one {1 membro} other {# membros}}"
},
"import": {
"title": "Importar contatos",
"drop_hint": "Clique para selecionar um arquivo vCard",
"file_types": "Arquivos .vcf ou .vcard",
"no_contacts": "Nenhum contato encontrado no arquivo",
"parse_error": "Falha ao analisar o arquivo vCard",
"found": "{count, plural, one {1 contato encontrado} other {# contatos encontrados}}",
"duplicate": "Duplicado",
"select_all": "Selecionar tudo",
"deselect_all": "Desmarcar tudo",
"selected": "{count, plural, one {1 selecionado} other {# selecionados}}",
"import_button": "Importar",
"importing": "Importando...",
"success": "{count, plural, one {1 contato importado} other {# contatos importados}}",
"failed": "Falha na importação",
"close": "Fechar",
"file_too_large": "Arquivo muito grande (máx. 5 MB)"
},
"export": {
"title": "Exportar contatos",
"success": "{count, plural, one {1 contato exportado} other {# contatos exportados}}"
},
"bulk": {
"selected": "{count, plural, one {1 selecionado} other {# selecionados}}",
"select_all": "Selecionar tudo",
"delete": "Excluir",
"delete_confirm": "Excluir {count, plural, one {1 contato} other {# contatos}}?",
"deleted": "{count, plural, one {1 contato excluído} other {# contatos excluídos}}",
"add_to_group": "Adicionar ao grupo",
"choose_group": "Escolher um grupo",
"adding_contacts": "Adicionando {count, plural, one {1 contato} other {# contatos}}",
"added_to_group": "Contatos adicionados ao grupo",
"export": "Exportar",
"clear": "Limpar seleção"
},
"toast": {
"created": "Contato criado",
"updated": "Contato atualizado",
@@ -796,5 +901,28 @@
"error_update": "Falha ao atualizar contato",
"error_delete": "Falha ao excluir contato"
}
},
"advanced_search": {
"title": "Pesquisa avançada",
"from": "De",
"from_placeholder": "E-mail ou nome do remetente",
"to": "Para",
"to_placeholder": "E-mail ou nome do destinatário",
"subject": "Assunto",
"subject_placeholder": "O assunto contém...",
"body": "Corpo",
"has_attachment": "Anexos",
"date_after": "Após",
"date_before": "Antes de",
"starred": "Favoritos",
"unread": "Não lidos",
"read": "Lidos",
"yes": "Sim",
"no": "Não",
"clear": "Limpar",
"clear_all": "Limpar tudo",
"filters_active": "{count} filtro",
"filters_active_plural": "{count} filtros",
"toggle_filters": "Filtros"
}
}
-8
View File
@@ -1,8 +0,0 @@
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = withNextIntl(nextConfig);
+4 -2
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);
+93
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
"@types/dompurify": "^3.0.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -24,6 +25,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -2416,6 +2418,23 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -3255,6 +3274,33 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -8293,6 +8339,53 @@
"node": ">=0.10"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
+2
View File
@@ -31,6 +31,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
"@types/dompurify": "^3.0.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -46,6 +47,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
retries: 0,
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: true,
},
});
+57 -5
View File
@@ -1,9 +1,61 @@
import createIntlMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createIntlMiddleware(routing);
const intlMiddleware = createIntlMiddleware(routing);
export function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
const scriptSrc = isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
: `'self' 'nonce-${nonce}'`;
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
const csp = [
`default-src 'self'`,
`script-src ${scriptSrc}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`connect-src ${connectSrc}`,
`frame-src 'none'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
].join("; ");
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
try {
intlResponse = intlMiddleware(request);
} catch (error) {
console.error('Locale middleware error:', error);
}
const response = intlResponse ?? NextResponse.next();
const existing = response.headers.get("x-middleware-override-headers");
response.headers.set(
"x-middleware-override-headers",
existing ? `${existing},x-nonce` : "x-nonce"
);
response.headers.set("x-middleware-request-x-nonce", nonce);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("X-XSS-Protection", "0");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()"
);
response.headers.set("Content-Security-Policy-Report-Only", csp);
return response;
}
export const config = {
// Skip all paths that should not be internationalized
matcher: ['/((?!api|_next|.*\\..*).*)']
matcher: ["/((?!api|_next|.*\\..*).*)"],
};
+516
View File
@@ -0,0 +1,516 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useContactStore } from '../contact-store';
import type { ContactCard } from '@/lib/jmap/types';
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
const makeContact = (overrides: Partial<ContactCard> = {}): ContactCard => ({
id: 'contact-1',
addressBookIds: { 'ab-1': true },
name: { components: [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }], isOrdered: true },
emails: { e0: { address: 'john@example.com' } },
...overrides,
});
const makeGroup = (overrides: Partial<ContactCard> = {}): ContactCard => ({
id: 'group-1',
addressBookIds: {},
kind: 'group',
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
members: {},
...overrides,
});
const defaultState = {
contacts: [],
addressBooks: [],
selectedContactId: null,
searchQuery: '',
isLoading: false,
error: null,
supportsSync: false,
selectedContactIds: new Set<string>(),
activeTab: 'all' as const,
};
describe('contact-store', () => {
beforeEach(() => {
useContactStore.setState(defaultState);
});
describe('addLocalContact', () => {
it('should append contact to array', () => {
useContactStore.getState().addLocalContact(makeContact());
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].id).toBe('contact-1');
});
it('should preserve existing contacts', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
expect(useContactStore.getState().contacts).toHaveLength(2);
});
});
describe('updateLocalContact', () => {
it('should update matching contact', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().updateLocalContact('c1', {
emails: { e0: { address: 'updated@example.com' } },
});
expect(useContactStore.getState().contacts[0].emails!.e0.address).toBe('updated@example.com');
});
it('should not modify other contacts', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } }));
useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } });
expect(useContactStore.getState().contacts[1].emails!.e0.address).toBe('c2@test.com');
});
it('should no-op for non-existent id', () => {
useContactStore.getState().addLocalContact(makeContact());
useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' });
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].kind).toBeUndefined();
});
});
describe('deleteLocalContact', () => {
it('should remove contact by id', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
useContactStore.getState().deleteLocalContact('c1');
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].id).toBe('c2');
});
it('should clear selectedContactId when deleting selected', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().setSelectedContact('c1');
useContactStore.getState().deleteLocalContact('c1');
expect(useContactStore.getState().selectedContactId).toBeNull();
});
it('should preserve selectedContactId when deleting other', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
useContactStore.getState().setSelectedContact('c1');
useContactStore.getState().deleteLocalContact('c2');
expect(useContactStore.getState().selectedContactId).toBe('c1');
});
});
describe('setSelectedContact', () => {
it('should set selectedContactId', () => {
useContactStore.getState().setSelectedContact('c1');
expect(useContactStore.getState().selectedContactId).toBe('c1');
});
it('should allow null to deselect', () => {
useContactStore.getState().setSelectedContact('c1');
useContactStore.getState().setSelectedContact(null);
expect(useContactStore.getState().selectedContactId).toBeNull();
});
});
describe('setSearchQuery', () => {
it('should set search query', () => {
useContactStore.getState().setSearchQuery('john');
expect(useContactStore.getState().searchQuery).toBe('john');
});
});
describe('setSupportsSync', () => {
it('should set supportsSync flag', () => {
useContactStore.getState().setSupportsSync(true);
expect(useContactStore.getState().supportsSync).toBe(true);
});
});
describe('setActiveTab', () => {
it('should set active tab', () => {
useContactStore.getState().setActiveTab('groups');
expect(useContactStore.getState().activeTab).toBe('groups');
});
});
describe('clearContacts', () => {
it('should reset all contact-related state', () => {
useContactStore.setState({
contacts: [makeContact()],
addressBooks: [{ id: 'ab-1', name: 'Default', isDefault: true }],
selectedContactId: 'c1',
searchQuery: 'test',
error: 'some error',
selectedContactIds: new Set(['c1', 'c2']),
activeTab: 'groups',
});
useContactStore.getState().clearContacts();
const state = useContactStore.getState();
expect(state.contacts).toEqual([]);
expect(state.addressBooks).toEqual([]);
expect(state.selectedContactId).toBeNull();
expect(state.searchQuery).toBe('');
expect(state.error).toBeNull();
expect(state.selectedContactIds.size).toBe(0);
expect(state.activeTab).toBe('all');
});
});
describe('toggleContactSelection', () => {
it('should add id to selection', () => {
useContactStore.getState().toggleContactSelection('c1');
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(true);
});
it('should remove id when already selected', () => {
useContactStore.getState().toggleContactSelection('c1');
useContactStore.getState().toggleContactSelection('c1');
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
});
it('should handle multiple selections independently', () => {
useContactStore.getState().toggleContactSelection('c1');
useContactStore.getState().toggleContactSelection('c2');
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
useContactStore.getState().toggleContactSelection('c1');
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
expect(useContactStore.getState().selectedContactIds.has('c2')).toBe(true);
});
});
describe('selectAllContacts', () => {
it('should set all provided ids', () => {
useContactStore.getState().selectAllContacts(['c1', 'c2', 'c3']);
expect(useContactStore.getState().selectedContactIds.size).toBe(3);
});
it('should replace previous selection', () => {
useContactStore.getState().toggleContactSelection('c0');
useContactStore.getState().selectAllContacts(['c1', 'c2']);
expect(useContactStore.getState().selectedContactIds.has('c0')).toBe(false);
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
});
});
describe('clearSelection', () => {
it('should empty the selection set', () => {
useContactStore.getState().selectAllContacts(['c1', 'c2']);
useContactStore.getState().clearSelection();
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
});
});
describe('getAutocomplete', () => {
beforeEach(() => {
useContactStore.setState({
contacts: [
makeContact({ id: 'c1' }),
makeContact({ id: 'c2', name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Smith' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
makeContact({ id: 'c3', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.org' } } }),
],
});
});
it('should return empty for empty query', () => {
expect(useContactStore.getState().getAutocomplete('')).toEqual([]);
});
it('should match by name', () => {
const results = useContactStore.getState().getAutocomplete('john');
expect(results).toHaveLength(1);
expect(results[0].email).toBe('john@example.com');
});
it('should match by email address', () => {
const results = useContactStore.getState().getAutocomplete('test.org');
expect(results).toHaveLength(1);
expect(results[0].name).toBe('Bob');
});
it('should be case insensitive', () => {
const results = useContactStore.getState().getAutocomplete('JANE');
expect(results).toHaveLength(1);
expect(results[0].email).toBe('jane@example.com');
});
it('should cap results at 10', () => {
const manyContacts = Array.from({ length: 15 }, (_, i) =>
makeContact({ id: `c${i}`, name: { components: [{ kind: 'given', value: `User${i}` }], isOrdered: true }, emails: { e0: { address: `user${i}@test.com` } } })
);
useContactStore.setState({ contacts: manyContacts });
const results = useContactStore.getState().getAutocomplete('user');
expect(results.length).toBeLessThanOrEqual(10);
});
it('should expand group members when group name matches', () => {
const member1 = makeContact({ id: 'm1', name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true }, emails: { e0: { address: 'alice@test.com' } } });
const member2 = makeContact({ id: 'm2', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.com' } } });
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
useContactStore.setState({ contacts: [member1, member2, group] });
const results = useContactStore.getState().getAutocomplete('Team');
expect(results).toHaveLength(2);
expect(results.map(r => r.email).sort()).toEqual(['alice@test.com', 'bob@test.com']);
});
it('should not include group itself in results', () => {
const group = makeGroup({ id: 'g1' });
useContactStore.setState({ contacts: [group] });
const results = useContactStore.getState().getAutocomplete('Team');
expect(results).toHaveLength(0);
});
it('should return contacts with multiple emails as separate results', () => {
const multi = makeContact({
id: 'multi',
name: { components: [{ kind: 'given', value: 'Multi' }], isOrdered: true },
emails: { e0: { address: 'a@test.com' }, e1: { address: 'b@test.com' } },
});
useContactStore.setState({ contacts: [multi] });
const results = useContactStore.getState().getAutocomplete('Multi');
expect(results).toHaveLength(2);
});
});
describe('getGroups', () => {
it('should return only group contacts', () => {
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
const groups = useContactStore.getState().getGroups();
expect(groups).toHaveLength(1);
expect(groups[0].kind).toBe('group');
});
it('should return empty when no groups', () => {
useContactStore.setState({ contacts: [makeContact()] });
expect(useContactStore.getState().getGroups()).toHaveLength(0);
});
});
describe('getIndividuals', () => {
it('should return non-group contacts', () => {
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
const individuals = useContactStore.getState().getIndividuals();
expect(individuals).toHaveLength(1);
expect(individuals[0].kind).not.toBe('group');
});
it('should include org and undefined kind', () => {
useContactStore.setState({
contacts: [
makeContact({ id: 'c1' }),
makeContact({ id: 'c2', kind: 'org' }),
makeGroup(),
],
});
expect(useContactStore.getState().getIndividuals()).toHaveLength(2);
});
});
describe('getGroupMembers', () => {
it('should return contacts whose ids are in group members', () => {
const m1 = makeContact({ id: 'm1' });
const m2 = makeContact({ id: 'm2' });
const nonMember = makeContact({ id: 'nm' });
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
useContactStore.setState({ contacts: [m1, m2, nonMember, group] });
const members = useContactStore.getState().getGroupMembers('g1');
expect(members).toHaveLength(2);
expect(members.map(m => m.id).sort()).toEqual(['m1', 'm2']);
});
it('should return empty for group with no members', () => {
useContactStore.setState({ contacts: [makeGroup({ id: 'g1', members: {} })] });
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(0);
});
it('should return empty for non-existent group', () => {
expect(useContactStore.getState().getGroupMembers('nonexistent')).toHaveLength(0);
});
it('should match by uid as well as id', () => {
const m1 = makeContact({ id: 'm1', uid: 'uid-m1' });
const group = makeGroup({ id: 'g1', members: { 'uid-m1': true } });
useContactStore.setState({ contacts: [m1, group] });
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
});
it('should exclude members with false value', () => {
const m1 = makeContact({ id: 'm1' });
const m2 = makeContact({ id: 'm2' });
const group = makeGroup({ id: 'g1', members: { m1: true, m2: false } });
useContactStore.setState({ contacts: [m1, m2, group] });
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
});
});
describe('createGroup (local mode)', () => {
it('should create group with local- prefix id', async () => {
await useContactStore.getState().createGroup(null, 'Friends', ['c1', 'c2']);
const contacts = useContactStore.getState().contacts;
expect(contacts).toHaveLength(1);
expect(contacts[0].id).toMatch(/^local-/);
expect(contacts[0].kind).toBe('group');
});
it('should set members from provided ids', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2']);
const group = useContactStore.getState().contacts[0];
expect(group.members).toEqual({ m1: true, m2: true });
});
it('should set group name', async () => {
await useContactStore.getState().createGroup(null, 'Work', []);
const group = useContactStore.getState().contacts[0];
expect(group.name?.components?.[0]?.value).toBe('Work');
});
});
describe('updateGroup (local mode)', () => {
it('should update group name', async () => {
await useContactStore.getState().createGroup(null, 'Old', []);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().updateGroup(null, groupId, 'New');
expect(useContactStore.getState().contacts[0].name?.components?.[0]?.value).toBe('New');
});
it('should preserve group members when renaming', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().updateGroup(null, groupId, 'Renamed');
expect(useContactStore.getState().contacts[0].members).toEqual({ m1: true });
});
});
describe('addMembersToGroup (local mode)', () => {
it('should add new members to group', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().addMembersToGroup(null, groupId, ['m2', 'm3']);
const members = useContactStore.getState().contacts[0].members;
expect(members).toEqual({ m1: true, m2: true, m3: true });
});
it('should no-op for non-existent group', async () => {
await useContactStore.getState().addMembersToGroup(null, 'nonexistent', ['m1']);
expect(useContactStore.getState().contacts).toHaveLength(0);
});
});
describe('removeMembersFromGroup (local mode)', () => {
it('should remove members from group', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2', 'm3']);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().removeMembersFromGroup(null, groupId, ['m2']);
const members = useContactStore.getState().contacts[0].members;
expect(members).toEqual({ m1: true, m3: true });
});
it('should no-op for group without members', async () => {
const group = makeGroup({ id: 'g1', members: undefined });
useContactStore.setState({ contacts: [group] });
await useContactStore.getState().removeMembersFromGroup(null, 'g1', ['m1']);
expect(useContactStore.getState().contacts[0].members).toBeUndefined();
});
});
describe('deleteGroup (local mode)', () => {
it('should remove group from contacts', async () => {
await useContactStore.getState().createGroup(null, 'Team', []);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().deleteGroup(null, groupId);
expect(useContactStore.getState().contacts).toHaveLength(0);
});
it('should clear selectedContactId when deleting selected group', async () => {
await useContactStore.getState().createGroup(null, 'Team', []);
const groupId = useContactStore.getState().contacts[0].id;
useContactStore.getState().setSelectedContact(groupId);
await useContactStore.getState().deleteGroup(null, groupId);
expect(useContactStore.getState().selectedContactId).toBeNull();
});
it('should preserve selectedContactId when deleting other group', async () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().setSelectedContact('c1');
await useContactStore.getState().createGroup(null, 'Team', []);
const groupId = useContactStore.getState().contacts[1].id;
await useContactStore.getState().deleteGroup(null, groupId);
expect(useContactStore.getState().selectedContactId).toBe('c1');
});
});
describe('bulkDeleteContacts (local mode)', () => {
it('should remove multiple contacts', async () => {
useContactStore.setState({
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' }), makeContact({ id: 'c3' })],
});
await useContactStore.getState().bulkDeleteContacts(null, ['c1', 'c3']);
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].id).toBe('c2');
});
it('should clear selection after bulk delete', async () => {
useContactStore.setState({
contacts: [makeContact({ id: 'c1' })],
selectedContactIds: new Set(['c1']),
});
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
});
it('should clear selectedContactId if deleted', async () => {
useContactStore.setState({
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' })],
selectedContactId: 'c1',
});
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
expect(useContactStore.getState().selectedContactId).toBeNull();
});
});
describe('bulkAddToGroup (local mode)', () => {
it('should add contacts to group and clear selection', async () => {
const m1 = makeContact({ id: 'm1' });
const group = makeGroup({ id: 'g1', members: {} });
useContactStore.setState({
contacts: [m1, group],
selectedContactIds: new Set(['m1']),
});
await useContactStore.getState().bulkAddToGroup(null, 'g1', ['m1']);
expect(useContactStore.getState().contacts.find(c => c.id === 'g1')?.members).toEqual({ m1: true });
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
});
});
describe('importContacts (local mode)', () => {
it('should import contacts with local- prefix ids', async () => {
const toImport = [makeContact({ id: 'orig-1' }), makeContact({ id: 'orig-2' })];
const count = await useContactStore.getState().importContacts(null, toImport);
expect(count).toBe(2);
expect(useContactStore.getState().contacts).toHaveLength(2);
expect(useContactStore.getState().contacts[0].id).toMatch(/^local-/);
});
});
describe('persistence/partialize', () => {
it('should persist contacts when supportsSync is false', () => {
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
const state = { contacts: [makeContact()], supportsSync: false };
const persisted = partialize(state);
expect(persisted.contacts).toHaveLength(1);
expect(persisted.supportsSync).toBe(false);
});
it('should persist empty contacts array when supportsSync is true', () => {
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
const state = { contacts: [makeContact()], supportsSync: true };
const persisted = partialize(state);
expect(persisted.contacts).toEqual([]);
expect(persisted.supportsSync).toBe(true);
});
});
});
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useIdentityStore } from '../identity-store';
import type { Identity } from '@/lib/jmap/types';
const makeIdentity = (overrides: Partial<Identity> = {}): Identity => ({
id: 'id-1',
name: 'Test User',
email: 'test@example.com',
mayDelete: true,
...overrides,
});
describe('identity-store', () => {
beforeEach(() => {
useIdentityStore.setState({
identities: [],
selectedIdentityId: null,
isLoading: false,
error: null,
subAddress: { recentTags: [], tagSuggestions: {} },
});
});
describe('setIdentities', () => {
it('should set identities list', () => {
const identities = [makeIdentity(), makeIdentity({ id: 'id-2', email: 'other@test.com' })];
useIdentityStore.getState().setIdentities(identities);
expect(useIdentityStore.getState().identities).toHaveLength(2);
});
it('should replace existing identities', () => {
useIdentityStore.getState().setIdentities([makeIdentity()]);
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-new' })]);
expect(useIdentityStore.getState().identities).toHaveLength(1);
expect(useIdentityStore.getState().identities[0].id).toBe('id-new');
});
});
describe('addIdentity', () => {
it('should append identity to list', () => {
useIdentityStore.getState().addIdentity(makeIdentity());
expect(useIdentityStore.getState().identities).toHaveLength(1);
});
it('should not remove existing identities', () => {
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-1' }));
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-2' }));
expect(useIdentityStore.getState().identities).toHaveLength(2);
});
});
describe('updateIdentityLocal', () => {
it('should update matching identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1', name: 'Old' })]);
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'New' });
expect(useIdentityStore.getState().identities[0].name).toBe('New');
});
it('should not modify other identities', () => {
useIdentityStore.getState().setIdentities([
makeIdentity({ id: 'id-1', name: 'First' }),
makeIdentity({ id: 'id-2', name: 'Second' }),
]);
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'Updated' });
expect(useIdentityStore.getState().identities[1].name).toBe('Second');
});
it('should no-op for non-existent identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity()]);
useIdentityStore.getState().updateIdentityLocal('nonexistent', { name: 'X' });
expect(useIdentityStore.getState().identities[0].name).toBe('Test User');
});
});
describe('removeIdentity', () => {
it('should remove identity by id', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
useIdentityStore.getState().removeIdentity('id-1');
expect(useIdentityStore.getState().identities).toHaveLength(1);
expect(useIdentityStore.getState().identities[0].id).toBe('id-2');
});
it('should clear selectedIdentityId when removing selected identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' })]);
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().removeIdentity('id-1');
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
});
it('should preserve selectedIdentityId when removing different identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().removeIdentity('id-2');
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
});
});
describe('selectIdentity', () => {
it('should set selectedIdentityId', () => {
useIdentityStore.getState().selectIdentity('id-1');
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
});
it('should allow null to deselect', () => {
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().selectIdentity(null);
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
});
});
describe('setLoading', () => {
it('should set loading state', () => {
useIdentityStore.getState().setLoading(true);
expect(useIdentityStore.getState().isLoading).toBe(true);
});
it('should clear loading state', () => {
useIdentityStore.getState().setLoading(true);
useIdentityStore.getState().setLoading(false);
expect(useIdentityStore.getState().isLoading).toBe(false);
});
});
describe('setError', () => {
it('should set error message', () => {
useIdentityStore.getState().setError('Something went wrong');
expect(useIdentityStore.getState().error).toBe('Something went wrong');
});
it('should clear error with null', () => {
useIdentityStore.getState().setError('error');
useIdentityStore.getState().setError(null);
expect(useIdentityStore.getState().error).toBeNull();
});
});
describe('clearIdentities', () => {
it('should clear identities and selection', () => {
useIdentityStore.getState().setIdentities([makeIdentity()]);
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().setError('old error');
useIdentityStore.getState().clearIdentities();
const state = useIdentityStore.getState();
expect(state.identities).toEqual([]);
expect(state.selectedIdentityId).toBeNull();
expect(state.error).toBeNull();
});
it('should not clear sub-address state', () => {
useIdentityStore.getState().addRecentTag('shopping');
useIdentityStore.getState().clearIdentities();
expect(useIdentityStore.getState().subAddress.recentTags).toContain('shopping');
});
});
describe('addRecentTag', () => {
it('should add tag to recent tags', () => {
useIdentityStore.getState().addRecentTag('shopping');
expect(useIdentityStore.getState().subAddress.recentTags).toEqual(['shopping']);
});
it('should prepend new tags', () => {
useIdentityStore.getState().addRecentTag('first');
useIdentityStore.getState().addRecentTag('second');
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('second');
});
it('should deduplicate tags by moving to front', () => {
useIdentityStore.getState().addRecentTag('a');
useIdentityStore.getState().addRecentTag('b');
useIdentityStore.getState().addRecentTag('a');
const tags = useIdentityStore.getState().subAddress.recentTags;
expect(tags).toEqual(['a', 'b']);
});
it('should cap at 10 recent tags', () => {
for (let i = 0; i < 15; i++) {
useIdentityStore.getState().addRecentTag(`tag-${i}`);
}
expect(useIdentityStore.getState().subAddress.recentTags).toHaveLength(10);
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('tag-14');
});
});
describe('addTagSuggestion', () => {
it('should add suggestion for domain', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
});
it('should not duplicate existing suggestion', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
});
it('should cap at 5 suggestions per domain', () => {
for (let i = 0; i < 8; i++) {
useIdentityStore.getState().addTagSuggestion('example.com', `tag-${i}`);
}
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toHaveLength(5);
});
it('should keep suggestions separate per domain', () => {
useIdentityStore.getState().addTagSuggestion('a.com', 'tag-a');
useIdentityStore.getState().addTagSuggestion('b.com', 'tag-b');
expect(useIdentityStore.getState().subAddress.tagSuggestions['a.com']).toEqual(['tag-a']);
expect(useIdentityStore.getState().subAddress.tagSuggestions['b.com']).toEqual(['tag-b']);
});
});
describe('getTagSuggestionsForDomain', () => {
it('should return suggestions for known domain', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
expect(useIdentityStore.getState().getTagSuggestionsForDomain('example.com')).toEqual(['promo']);
});
it('should return empty array for unknown domain', () => {
expect(useIdentityStore.getState().getTagSuggestionsForDomain('unknown.com')).toEqual([]);
});
});
describe('clearRecentTags', () => {
it('should clear recent tags', () => {
useIdentityStore.getState().addRecentTag('a');
useIdentityStore.getState().addRecentTag('b');
useIdentityStore.getState().clearRecentTags();
expect(useIdentityStore.getState().subAddress.recentTags).toEqual([]);
});
it('should not clear tag suggestions', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
useIdentityStore.getState().clearRecentTags();
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
});
});
describe('persistence', () => {
it('should only persist subAddress state', () => {
const { partialize } = (useIdentityStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
const fullState = {
identities: [makeIdentity()],
selectedIdentityId: 'id-1',
isLoading: true,
error: 'err',
subAddress: { recentTags: ['a'], tagSuggestions: {} },
};
const persisted = partialize(fullState);
expect(persisted).toHaveProperty('subAddress');
expect(persisted).not.toHaveProperty('identities');
expect(persisted).not.toHaveProperty('selectedIdentityId');
expect(persisted).not.toHaveProperty('isLoading');
expect(persisted).not.toHaveProperty('error');
});
});
});
+19 -5
View File
@@ -4,6 +4,7 @@ import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store';
import type { Identity } from '@/lib/jmap/types';
interface AuthState {
@@ -16,7 +17,7 @@ interface AuthState {
identities: Identity[];
primaryIdentity: Identity | null;
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
login: (serverUrl: string, username: string, password: string, totp?: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
@@ -34,12 +35,13 @@ export const useAuthStore = create<AuthState>()(
identities: [],
primaryIdentity: null,
login: async (serverUrl, username, password) => {
login: async (serverUrl, username, password, totp) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
set({ isLoading: true, error: null });
try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, password);
const client = new JMAPClient(serverUrl, username, effectivePassword);
// Try to connect
await client.connect();
@@ -55,12 +57,21 @@ export const useAuthStore = create<AuthState>()(
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
contactStore.fetchAddressBooks(client).catch(() => {});
contactStore.fetchContacts(client).catch(() => {});
contactStore.fetchAddressBooks(client).catch((err) => console.error('Failed to fetch address books:', err));
contactStore.fetchContacts(client).catch((err) => console.error('Failed to fetch contacts:', err));
} else {
useContactStore.getState().setSupportsSync(false);
}
// Initialize vacation responder if supported
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
vacationStore.fetchVacationResponse(client).catch((err) => console.error('Failed to fetch vacation response:', err));
} else {
vacationStore.setSupported(false);
}
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
@@ -138,6 +149,9 @@ export const useAuthStore = create<AuthState>()(
// Clear contact store state
useContactStore.getState().clearContacts();
// Clear vacation store state
useVacationStore.getState().clearState();
},
checkAuth: async () => {
+217 -2
View File
@@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { JMAPClient } from '@/lib/jmap/client';
function getContactDisplayName(contact: ContactCard): string {
export function getContactDisplayName(contact: ContactCard): string {
if (contact.name?.components) {
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
@@ -35,6 +35,9 @@ interface ContactStore {
error: string | null;
supportsSync: boolean;
selectedContactIds: Set<string>;
activeTab: 'all' | 'groups';
fetchContacts: (client: JMAPClient) => Promise<void>;
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
@@ -48,9 +51,27 @@ interface ContactStore {
setSelectedContact: (id: string | null) => void;
setSearchQuery: (query: string) => void;
setSupportsSync: (supports: boolean) => void;
setActiveTab: (tab: 'all' | 'groups') => void;
clearContacts: () => void;
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
getGroups: () => ContactCard[];
getIndividuals: () => ContactCard[];
getGroupMembers: (groupId: string) => ContactCard[];
createGroup: (client: JMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
updateGroup: (client: JMAPClient | null, groupId: string, name: string) => Promise<void>;
addMembersToGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
removeMembersFromGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
toggleContactSelection: (id: string) => void;
selectAllContacts: (ids: string[]) => void;
clearSelection: () => void;
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
}
export const useContactStore = create<ContactStore>()(
@@ -63,6 +84,8 @@ export const useContactStore = create<ContactStore>()(
isLoading: false,
error: null,
supportsSync: false,
selectedContactIds: new Set<string>(),
activeTab: 'all' as const,
fetchContacts: async (client) => {
set({ isLoading: true, error: null });
@@ -81,6 +104,7 @@ export const useContactStore = create<ContactStore>()(
set({ addressBooks });
} catch (error) {
console.error('Failed to fetch address books:', error);
set({ error: 'Failed to fetch address books' });
}
},
@@ -148,6 +172,7 @@ export const useContactStore = create<ContactStore>()(
setSelectedContact: (id) => set({ selectedContactId: id }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSupportsSync: (supports) => set({ supportsSync: supports }),
setActiveTab: (tab) => set({ activeTab: tab }),
clearContacts: () => set({
contacts: [],
@@ -155,6 +180,8 @@ export const useContactStore = create<ContactStore>()(
selectedContactId: null,
searchQuery: '',
error: null,
selectedContactIds: new Set<string>(),
activeTab: 'all',
}),
getAutocomplete: (query) => {
@@ -165,6 +192,22 @@ export const useContactStore = create<ContactStore>()(
const results: Array<{ name: string; email: string }> = [];
for (const contact of contacts) {
if (contact.kind === 'group') {
const groupName = getContactDisplayName(contact);
if (groupName.toLowerCase().includes(lower)) {
const members = get().getGroupMembers(contact.id);
for (const member of members) {
const memberName = getContactDisplayName(member);
const memberEmails = member.emails ? Object.values(member.emails) : [];
for (const emailEntry of memberEmails) {
if (!emailEntry.address) continue;
results.push({ name: memberName, email: emailEntry.address });
}
}
}
continue;
}
const name = getContactDisplayName(contact);
const emails = contact.emails ? Object.values(contact.emails) : [];
@@ -183,6 +226,178 @@ export const useContactStore = create<ContactStore>()(
return results;
},
getGroups: () => {
return get().contacts.filter(c => c.kind === 'group');
},
getIndividuals: () => {
return get().contacts.filter(c => c.kind !== 'group');
},
getGroupMembers: (groupId) => {
const { contacts } = get();
const group = contacts.find(c => c.id === groupId);
if (!group?.members) return [];
const memberIds = Object.keys(group.members).filter(k => group.members![k]);
return contacts.filter(c => memberIds.includes(c.id) || memberIds.includes(c.uid || ''));
},
createGroup: async (client, name, memberIds) => {
const members: Record<string, boolean> = {};
memberIds.forEach(id => { members[id] = true; });
const groupData: Partial<ContactCard> = {
kind: 'group',
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
members,
};
if (client && get().supportsSync) {
const created = await client.createContact(groupData);
set((state) => ({ contacts: [...state.contacts, created] }));
} else {
const localGroup: ContactCard = {
id: `local-${crypto.randomUUID()}`,
addressBookIds: {},
...groupData,
} as ContactCard;
set((state) => ({ contacts: [...state.contacts, localGroup] }));
}
},
updateGroup: async (client, groupId, name) => {
const updates: Partial<ContactCard> = {
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
};
if (client && get().supportsSync) {
await client.updateContact(groupId, updates);
}
set((state) => ({
contacts: state.contacts.map(c =>
c.id === groupId ? { ...c, ...updates } : c
),
}));
},
addMembersToGroup: async (client, groupId, memberIds) => {
const { contacts } = get();
const group = contacts.find(c => c.id === groupId);
if (!group) return;
const newMembers = { ...group.members };
memberIds.forEach(id => { newMembers[id] = true; });
const updates: Partial<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {
await client.updateContact(groupId, updates);
}
set((state) => ({
contacts: state.contacts.map(c =>
c.id === groupId ? { ...c, members: newMembers } : c
),
}));
},
removeMembersFromGroup: async (client, groupId, memberIds) => {
const { contacts } = get();
const group = contacts.find(c => c.id === groupId);
if (!group?.members) return;
const newMembers = { ...group.members };
memberIds.forEach(id => { delete newMembers[id]; });
const updates: Partial<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {
await client.updateContact(groupId, updates);
}
set((state) => ({
contacts: state.contacts.map(c =>
c.id === groupId ? { ...c, members: newMembers } : c
),
}));
},
deleteGroup: async (client, groupId) => {
if (client && get().supportsSync) {
await client.deleteContact(groupId);
}
set((state) => ({
contacts: state.contacts.filter(c => c.id !== groupId),
selectedContactId: state.selectedContactId === groupId ? null : state.selectedContactId,
}));
},
toggleContactSelection: (id) => set((state) => {
const next = new Set(state.selectedContactIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedContactIds: next };
}),
selectAllContacts: (ids) => set({ selectedContactIds: new Set(ids) }),
clearSelection: () => set({ selectedContactIds: new Set<string>() }),
bulkDeleteContacts: async (client, ids) => {
set({ error: null });
const { supportsSync } = get();
const deletedIds = new Set(ids);
if (client && supportsSync) {
for (const id of ids) {
try {
await client.deleteContact(id);
} catch (error) {
console.error(`Failed to delete contact ${id}:`, error);
deletedIds.delete(id);
}
}
if (deletedIds.size < ids.length) {
set({ error: `Failed to delete ${ids.length - deletedIds.size} contact(s)` });
}
}
set((state) => ({
contacts: state.contacts.filter(c => !deletedIds.has(c.id)),
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
selectedContactIds: new Set<string>(),
}));
},
bulkAddToGroup: async (client, groupId, contactIds) => {
await get().addMembersToGroup(client, groupId, contactIds);
set({ selectedContactIds: new Set<string>() });
},
importContacts: async (client, contacts) => {
const { supportsSync } = get();
let imported = 0;
for (const contact of contacts) {
try {
if (client && supportsSync) {
const { id: _id, ...data } = contact;
const created = await client.createContact(data);
set((state) => ({ contacts: [...state.contacts, created] }));
} else {
const localContact: ContactCard = {
...contact,
id: `local-${crypto.randomUUID()}`,
};
set((state) => ({ contacts: [...state.contacts, localContact] }));
}
imported++;
} catch (error) {
console.error('Failed to import contact:', error);
}
}
return imported;
},
}),
{
name: 'contact-storage',
@@ -194,5 +409,5 @@ export const useContactStore = create<ContactStore>()(
)
);
export { getContactDisplayName, getContactPrimaryEmail };
export { getContactPrimaryEmail };
export type { ContactName };
+90 -7
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
interface EmailStore {
emails: Email[];
@@ -23,9 +24,14 @@ interface EmailStore {
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
expandedThreadIds: Set<string>;
threadEmailsCache: Map<string, Email[]>;
isLoadingThread: string | null;
// Advanced search state
searchFilters: SearchFilters;
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
@@ -51,6 +57,10 @@ interface EmailStore {
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>;
advancedSearch: (client: JMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
// Batch operations
@@ -106,6 +116,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
threadEmailsCache: new Map(),
isLoadingThread: null,
// Advanced search state
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
searchAbortController: null,
// Spam undo cache
spamUndoCache: new Map(),
@@ -222,15 +237,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
let result;
// Check if we're in search mode
if (searchQuery) {
// Load more search results (scoped to current mailbox)
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
if (searchQuery || hasFilters) {
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
} else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
}
} else {
// Load more from mailbox
// Find the mailbox to get its accountId (for shared folder support)
@@ -617,6 +638,68 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get();
if (searchAbortController) {
searchAbortController.abort();
}
const controller = new AbortController();
set({
isLoading: true,
error: null,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: controller,
});
try {
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return;
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
});
} catch (error) {
if (controller.signal.aborted) return;
set({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: null,
});
}
},
setSearchFilters: (filters) => {
set((state) => ({
searchFilters: { ...state.searchFilters, ...filters },
}));
},
clearSearchFilters: () => {
set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } });
},
toggleAdvancedSearch: () => {
set((state) => ({ isAdvancedSearchOpen: !state.isAdvancedSearchOpen }));
},
toggleStar: async (client, emailId) => {
try {
const email = get().emails.find(e => e.id === emailId);
+94
View File
@@ -0,0 +1,94 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
interface VacationStore {
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
isLoading: boolean;
isSaving: boolean;
error: string | null;
isSupported: boolean;
fetchVacationResponse: (client: JMAPClient) => Promise<void>;
updateVacationResponse: (client: JMAPClient, updates: {
isEnabled?: boolean;
fromDate?: string | null;
toDate?: string | null;
subject?: string;
textBody?: string;
htmlBody?: string | null;
}) => Promise<void>;
setSupported: (supported: boolean) => void;
clearState: () => void;
}
export const useVacationStore = create<VacationStore>()((set) => ({
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
fetchVacationResponse: async (client) => {
set({ isLoading: true, error: null });
try {
const vacation = await client.getVacationResponse();
set({
isEnabled: vacation.isEnabled,
fromDate: vacation.fromDate,
toDate: vacation.toDate,
subject: vacation.subject || '',
textBody: vacation.textBody || '',
htmlBody: vacation.htmlBody,
isLoading: false,
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'fetch_error',
});
}
},
updateVacationResponse: async (client, updates) => {
set({ isSaving: true, error: null });
try {
await client.setVacationResponse(updates);
set((state) => ({
...state,
...updates,
isSaving: false,
}));
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'save_error',
});
throw error;
}
},
setSupported: (supported) => set({ isSupported: supported }),
clearState: () => set({
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
}),
}));
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
});
+18
View File
@@ -0,0 +1,18 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
useParams: () => ({ locale: 'en' }),
usePathname: () => '/en',
}));
afterEach(() => {
cleanup();
});