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:
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
.env*
|
||||
!.env.example
|
||||
.claude/
|
||||
scripts/
|
||||
TODO.md
|
||||
CLAUDE.md
|
||||
*.md
|
||||
!README.md
|
||||
+35
-2
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+383
-39
@@ -1,19 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactList } from "@/components/contacts/contact-list";
|
||||
import { ContactDetail } from "@/components/contacts/contact-detail";
|
||||
import { ContactForm } from "@/components/contacts/contact-form";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { ContactGroupList } from "@/components/contacts/contact-group-list";
|
||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
|
||||
type View = "list" | "detail" | "create" | "edit";
|
||||
type View =
|
||||
| "list"
|
||||
| "detail"
|
||||
| "create"
|
||||
| "edit"
|
||||
| "group-detail"
|
||||
| "group-create"
|
||||
| "group-edit"
|
||||
| "import"
|
||||
| "bulk-add-to-group";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
@@ -24,8 +39,11 @@ export default function ContactsPage() {
|
||||
selectedContactId,
|
||||
searchQuery,
|
||||
supportsSync,
|
||||
activeTab,
|
||||
selectedContactIds,
|
||||
setSelectedContact,
|
||||
setSearchQuery,
|
||||
setActiveTab,
|
||||
fetchContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
@@ -33,9 +51,24 @@ export default function ContactsPage() {
|
||||
addLocalContact,
|
||||
updateLocalContact,
|
||||
deleteLocalContact,
|
||||
getGroups,
|
||||
getIndividuals,
|
||||
getGroupMembers,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
addMembersToGroup,
|
||||
removeMembersFromGroup,
|
||||
deleteGroup,
|
||||
toggleContactSelection,
|
||||
selectAllContacts,
|
||||
clearSelection,
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,10 +84,15 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
|
||||
const groups = useMemo(() => getGroups(), [contacts]);
|
||||
const individuals = useMemo(() => getIndividuals(), [contacts]);
|
||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
||||
|
||||
const handleSelectContact = (id: string) => {
|
||||
setSelectedContact(id);
|
||||
clearSelection();
|
||||
setView("detail");
|
||||
};
|
||||
|
||||
@@ -79,7 +117,8 @@ export default function ContactsPage() {
|
||||
}
|
||||
toast.success(t("toast.deleted"));
|
||||
setView("list");
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Failed to delete contact:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
};
|
||||
@@ -114,57 +153,362 @@ export default function ContactsPage() {
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setView(selectedContact ? "detail" : "list");
|
||||
if (view === "group-create" || view === "group-edit") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "import") {
|
||||
setView("list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
setView("list");
|
||||
} else {
|
||||
setView(selectedContact ? "detail" : "list");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectGroup = (id: string) => {
|
||||
setSelectedGroupId(id);
|
||||
setView("group-detail");
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
setSelectedGroupId(null);
|
||||
setView("group-create");
|
||||
};
|
||||
|
||||
const handleEditGroup = () => {
|
||||
setView("group-edit");
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
if (!selectedGroup) return;
|
||||
if (!window.confirm(t("groups.delete_confirm"))) return;
|
||||
|
||||
try {
|
||||
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
|
||||
toast.success(t("toast.deleted"));
|
||||
setSelectedGroupId(null);
|
||||
setView("list");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete group:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveGroup = useCallback(async (name: string, memberIds: string[]) => {
|
||||
const jmapClient = supportsSync && client ? client : null;
|
||||
if (view === "group-edit" && selectedGroup) {
|
||||
await updateGroup(jmapClient, selectedGroup.id, name);
|
||||
const currentMemberIds = selectedGroup.members
|
||||
? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k])
|
||||
: [];
|
||||
const toAdd = memberIds.filter(id => !currentMemberIds.includes(id));
|
||||
const toRemove = currentMemberIds.filter(id => !memberIds.includes(id));
|
||||
if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd);
|
||||
if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove);
|
||||
toast.success(t("toast.updated"));
|
||||
setView("group-detail");
|
||||
} else {
|
||||
await createGroup(jmapClient, name, memberIds);
|
||||
toast.success(t("toast.created"));
|
||||
setView("list");
|
||||
}
|
||||
}, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
|
||||
|
||||
const handleRemoveGroupMember = async (memberId: string) => {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await removeMembersFromGroup(
|
||||
supportsSync && client ? client : null,
|
||||
selectedGroup.id,
|
||||
[memberId]
|
||||
);
|
||||
toast.success(t("toast.updated"));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove group member:', error);
|
||||
toast.error(t("toast.error_update"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedContactIds.size === 0) return;
|
||||
if (!window.confirm(t("bulk.delete_confirm", { count: selectedContactIds.size }))) return;
|
||||
|
||||
try {
|
||||
await bulkDeleteContacts(
|
||||
supportsSync && client ? client : null,
|
||||
Array.from(selectedContactIds)
|
||||
);
|
||||
toast.success(t("bulk.deleted", { count: selectedContactIds.size }));
|
||||
setView("list");
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk delete contacts:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAddToGroup = () => {
|
||||
if (selectedContactIds.size === 0) return;
|
||||
if (groups.length === 0) {
|
||||
setView("group-create");
|
||||
return;
|
||||
}
|
||||
setView("bulk-add-to-group");
|
||||
};
|
||||
|
||||
const handleBulkExport = () => {
|
||||
const toExport = contacts.filter(c => selectedContactIds.has(c.id));
|
||||
if (toExport.length > 0) {
|
||||
exportContacts(toExport);
|
||||
toast.success(t("export.success", { count: toExport.length }));
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAddToGroupConfirm = async (groupId: string) => {
|
||||
try {
|
||||
await bulkAddToGroup(
|
||||
supportsSync && client ? client : null,
|
||||
groupId,
|
||||
Array.from(selectedContactIds)
|
||||
);
|
||||
toast.success(t("bulk.added_to_group"));
|
||||
setView("list");
|
||||
} catch (error) {
|
||||
console.error('Failed to add contacts to group:', error);
|
||||
toast.error(t("toast.error_update"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
importedContacts
|
||||
);
|
||||
}, [supportsSync, client, importContacts]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="w-80 border-r border-border flex flex-col">
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/")}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_mail")}
|
||||
</Button>
|
||||
</div>
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
<ContactList
|
||||
contacts={contacts}
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
supportsSync={supportsSync}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{view === "create" && (
|
||||
<ContactForm onSave={handleSaveNew} onCancel={handleCancel} />
|
||||
)}
|
||||
{view === "edit" && selectedContact && (
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
return (
|
||||
<ContactForm
|
||||
contact={selectedContact}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
{(view === "list" || view === "detail") && (
|
||||
);
|
||||
|
||||
case "group-detail":
|
||||
if (!selectedGroup) return null;
|
||||
return (
|
||||
<ContactGroupDetail
|
||||
group={selectedGroup}
|
||||
members={selectedGroupMembers}
|
||||
onEdit={handleEditGroup}
|
||||
onDelete={handleDeleteGroup}
|
||||
onRemoveMember={handleRemoveGroupMember}
|
||||
onSelectMember={(id) => {
|
||||
setSelectedContact(id);
|
||||
setActiveTab("all");
|
||||
setView("detail");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "group-create":
|
||||
return (
|
||||
<ContactGroupForm
|
||||
individuals={individuals}
|
||||
onSave={handleSaveGroup}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
case "group-edit":
|
||||
if (!selectedGroup) return null;
|
||||
return (
|
||||
<ContactGroupForm
|
||||
group={selectedGroup}
|
||||
individuals={individuals}
|
||||
currentMemberIds={selectedGroupMembers.map(m => m.id)}
|
||||
onSave={handleSaveGroup}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
case "import":
|
||||
return (
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
onImport={handleImport}
|
||||
onClose={handleCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
case "bulk-add-to-group":
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{t("bulk.choose_group")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("bulk.adding_contacts", { count: selectedContactIds.size })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-border">
|
||||
{groups.map((group) => {
|
||||
const gName = getContactDisplayName(group);
|
||||
const memberCount = group.members
|
||||
? Object.values(group.members).filter(Boolean).length
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => handleBulkAddToGroupConfirm(group.id)}
|
||||
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{gName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("groups.member_count", { count: memberCount })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-border">
|
||||
<Button variant="outline" onClick={handleCancel} className="w-full">
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<ContactDetail
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="w-80 border-r border-border flex flex-col">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/")}
|
||||
className="justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_mail")}
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setView("import")}
|
||||
title={t("import.title")}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
if (contacts.length > 0) {
|
||||
exportContacts(contacts.filter(c => c.kind !== "group"));
|
||||
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
|
||||
}
|
||||
}}
|
||||
title={t("export.title")}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("all")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
activeTab === "all"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<BookUser className="w-4 h-4" />
|
||||
{t("tabs.all")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("groups")}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
activeTab === "groups"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
{t("tabs.groups")}
|
||||
{groups.length > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
|
||||
{groups.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "all" ? (
|
||||
<ContactList
|
||||
contacts={contacts}
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
supportsSync={supportsSync}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
onSelectAll={selectAllContacts}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkExport={handleBulkExport}
|
||||
/>
|
||||
) : (
|
||||
<ContactGroupList
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onSelectGroup={handleSelectGroup}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
searchQuery={searchQuery}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-52
@@ -1,39 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { notFound } from "next/navigation";
|
||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!(locales as readonly string[]).includes(locale)) notFound();
|
||||
|
||||
// Load messages for the current locale
|
||||
let messages;
|
||||
try {
|
||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||
@@ -42,36 +22,10 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
const stored = localStorage.getItem('theme-storage');
|
||||
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const resolved = theme === 'system' ? systemTheme : theme;
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
</body>
|
||||
</html>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { Mail, AlertCircle, Loader2, X } from "lucide-react";
|
||||
import { Mail, AlertCircle, Loader2, X, ShieldCheck } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -20,6 +20,8 @@ export default function LoginPage() {
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [showTotpField, setShowTotpField] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
|
||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
@@ -223,7 +225,8 @@ export default function LoginPage() {
|
||||
const success = await login(
|
||||
serverUrl,
|
||||
formData.username,
|
||||
formData.password
|
||||
formData.password,
|
||||
showTotpField && totpCode ? totpCode : undefined
|
||||
);
|
||||
|
||||
if (success) {
|
||||
@@ -250,7 +253,9 @@ export default function LoginPage() {
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
{error === 'invalid_credentials' && showTotpField
|
||||
? t('error.totp_invalid')
|
||||
: t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,6 +320,34 @@ export default function LoginPage() {
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
{/* TOTP Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowTotpField(!showTotpField);
|
||||
if (showTotpField) setTotpCode("");
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
{showTotpField ? t("totp_hide") : t("totp_toggle")}
|
||||
</button>
|
||||
|
||||
{/* TOTP Input */}
|
||||
{showTotpField && (
|
||||
<Input
|
||||
id="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
|
||||
placeholder={t("totp_placeholder")}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
+32
-1
@@ -27,6 +27,8 @@ import {
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
|
||||
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
@@ -77,6 +79,12 @@ export default function Home() {
|
||||
clearNewEmailNotification,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
searchFilters,
|
||||
isAdvancedSearchOpen,
|
||||
setSearchFilters,
|
||||
clearSearchFilters,
|
||||
toggleAdvancedSearch,
|
||||
advancedSearch,
|
||||
} = useEmailStore();
|
||||
|
||||
// Play notification sound for new emails
|
||||
@@ -575,16 +583,27 @@ export default function Home() {
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
await searchEmails(client, query);
|
||||
setSearchQuery(query);
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
} else {
|
||||
await searchEmails(client, query);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = async () => {
|
||||
setSearchQuery("");
|
||||
clearSearchFilters();
|
||||
if (client && selectedMailbox) {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = async () => {
|
||||
if (!client) return;
|
||||
await advancedSearch(client);
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -786,6 +805,18 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AdvancedSearchPanel
|
||||
filters={searchFilters}
|
||||
isOpen={isAdvancedSearchOpen}
|
||||
onFiltersChange={setSearchFilters}
|
||||
onClear={() => {
|
||||
clearSearchFilters();
|
||||
if (client) advancedSearch(client);
|
||||
}}
|
||||
onSearch={handleAdvancedSearch}
|
||||
onClose={toggleAdvancedSearch}
|
||||
/>
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
emails={emails}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Health check thresholds
|
||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
||||
@@ -86,6 +87,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Health check', { status, detailed });
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: httpStatus,
|
||||
headers: {
|
||||
@@ -96,6 +99,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Health check failed', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
|
||||
+58
-8
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<ContactListItem
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
isSelected={contact.id === selectedContactId}
|
||||
onClick={() => onSelectContact(contact.id)}
|
||||
/>
|
||||
<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
|
||||
contact={contact}
|
||||
isSelected={contact.id === selectedContactId}
|
||||
onClick={() => onSelectContact(contact.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+113
-54
@@ -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) {
|
||||
handleLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentTarget = observerTarget.current;
|
||||
if (currentTarget) {
|
||||
observer.observe(currentTarget);
|
||||
if (lastVirtualItemIndex === undefined) return;
|
||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||
|
||||
return () => {
|
||||
if (currentTarget) {
|
||||
observer.unobserve(currentTarget);
|
||||
}
|
||||
};
|
||||
}, [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)
|
||||
);
|
||||
if (index >= 0) {
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmailId]);
|
||||
|
||||
// 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,24 +340,47 @@ 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) => (
|
||||
<ThreadListItem
|
||||
key={thread.threadId}
|
||||
thread={thread}
|
||||
isExpanded={expandedThreadIds.has(thread.threadId)}
|
||||
selectedEmailId={selectedEmailId}
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
))}
|
||||
<>
|
||||
<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
|
||||
thread={thread}
|
||||
isExpanded={expandedThreadIds.has(thread.threadId)}
|
||||
selectedEmailId={selectedEmailId}
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
</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)}
|
||||
@@ -399,4 +458,4 @@ export function EmailList({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,90 +34,41 @@ 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;
|
||||
interface SingleEmailItemProps {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
}
|
||||
|
||||
// Get color tag from thread
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : 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];
|
||||
|
||||
// Check if latest email is selected
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
// 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
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200",
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
isSelected
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "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"
|
||||
isUnread && !colorTag && "bg-accent/30"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
@@ -125,257 +76,281 @@ export function ThreadListItem({
|
||||
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>
|
||||
)}
|
||||
<div className="w-6 flex-shrink-0" />
|
||||
|
||||
{/* Unread indicator */}
|
||||
{hasUnread && (
|
||||
{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={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
name={sender?.name}
|
||||
email={sender?.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
|
||||
isUnread
|
||||
? "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}
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasStarred && (
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{hasAttachment && (
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
hasUnread
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
{email.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}
|
||||
/>
|
||||
))
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single email item (for threads with only 1 email)
|
||||
function SingleEmailItem({
|
||||
email,
|
||||
selected,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
showPreview,
|
||||
colorTag,
|
||||
}: {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
}) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !colorTag && "bg-accent/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Spacer for alignment with thread items */}
|
||||
<div className="w-6 flex-shrink-0" />
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,33 +425,36 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
{/* Search + Advanced Filter Toggle */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 py-3">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
onClearSearch?.();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t('clear_search')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<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"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
onClearSearch?.();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t('clear_search')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"]');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+133
-5
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+133
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-5
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+133
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Generated
+93
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
+91
-8
@@ -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;
|
||||
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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, './'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user