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
|
# JMAP Webmail Configuration
|
||||||
# Copy this file to .env.local and fill in your values
|
# 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
|
# App name displayed in the UI
|
||||||
NEXT_PUBLIC_APP_NAME=JMAP Webmail
|
APP_NAME=JMAP Webmail
|
||||||
|
|
||||||
# JMAP server URL (required)
|
# JMAP server URL (required)
|
||||||
# This is the URL of your JMAP-compatible mail server
|
# 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
|
- Mark as read/unread, star/unstar
|
||||||
- Archive and delete with configurable behavior
|
- Archive and delete with configurable behavior
|
||||||
- Color tags/labels for email organization
|
- 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
|
### User Interface
|
||||||
- Clean, minimalist three-pane layout
|
- 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
|
- Contact management with search and filtering
|
||||||
- JMAP server sync (RFC 9553/9610) with local fallback
|
- JMAP server sync (RFC 9553/9610) with local fallback
|
||||||
- Email autocomplete from contacts in composer
|
- 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
|
### Security & Privacy
|
||||||
- External content blocked by default
|
- 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
|
- HTML sanitization with DOMPurify
|
||||||
- SPF/DKIM/DMARC status indicators
|
- SPF/DKIM/DMARC status indicators
|
||||||
- No password storage (session-based auth)
|
- No password storage (session-based auth)
|
||||||
|
- TOTP two-factor authentication support
|
||||||
- Shared folder support with proper permissions
|
- Shared folder support with proper permissions
|
||||||
- Newsletter unsubscribe support (RFC 2369)
|
- Newsletter unsubscribe support (RFC 2369)
|
||||||
|
- CSP headers and security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
|
||||||
|
|
||||||
### Internationalization
|
### Internationalization
|
||||||
- 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese
|
- 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese
|
||||||
- Automatic browser language detection
|
- Automatic browser language detection
|
||||||
- Persistent language preference
|
- 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
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: [Next.js 16](https://nextjs.org/) with App Router
|
- **Framework**: [Next.js 16](https://nextjs.org/) with App Router
|
||||||
@@ -135,6 +152,19 @@ npm run build
|
|||||||
npm start
|
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
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|||||||
+27
-14
@@ -17,6 +17,7 @@ This document tracks the development status and planned features for JMAP Webmai
|
|||||||
- [x] Logout functionality
|
- [x] Logout functionality
|
||||||
- [x] Authentication error handling
|
- [x] Authentication error handling
|
||||||
- [x] JMAP identities for sender address
|
- [x] JMAP identities for sender address
|
||||||
|
- [x] TOTP two-factor authentication (Stalwart-compatible)
|
||||||
|
|
||||||
### JMAP Server Connection
|
### JMAP Server Connection
|
||||||
- [x] Session establishment and keep-alive
|
- [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] Delete and archive
|
||||||
- [x] Color tags/labels
|
- [x] Color tags/labels
|
||||||
- [x] Full-text search
|
- [x] Full-text search
|
||||||
|
- [x] Advanced search with JMAP filter panel, search chips, and cross-mailbox queries
|
||||||
- [x] Attachment upload and download
|
- [x] Attachment upload and download
|
||||||
- [x] Batch operations (multi-select)
|
- [x] Batch operations (multi-select)
|
||||||
- [x] Quick reply form
|
- [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] Loading states and skeletons
|
||||||
- [x] Smooth transitions and animations
|
- [x] Smooth transitions and animations
|
||||||
- [x] Infinite scroll pagination
|
- [x] Infinite scroll pagination
|
||||||
|
- [x] Virtual scrolling for large email lists
|
||||||
- [x] Error boundaries
|
- [x] Error boundaries
|
||||||
- [x] Settings page with preferences
|
- [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] WCAG 2.0 Level AA color contrast compliance
|
||||||
- [x] Newsletter unsubscribe support (RFC 2369)
|
- [x] Newsletter unsubscribe support (RFC 2369)
|
||||||
- [x] XSS attack prevention with comprehensive validation
|
- [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
|
### Identity Management
|
||||||
- [x] Multiple sender identities (name, email, signature)
|
- [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] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook)
|
||||||
- [x] Email autocomplete from contacts
|
- [x] Email autocomplete from contacts
|
||||||
- [x] Contacts integration in email composer (To/Cc/Bcc)
|
- [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)
|
- [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
|
### Email Display
|
||||||
- [x] Proper email layout without horizontal scroll or clipping
|
- [x] Proper email layout without horizontal scroll or clipping
|
||||||
- [x] Blocked image container collapsing (no empty spaces in newsletters)
|
- [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 validation utilities (57 tests)
|
||||||
- [x] Unit tests for email sanitization (27 tests)
|
- [x] Unit tests for email sanitization (27 tests)
|
||||||
- [x] Unit tests for color transformation (40 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] XSS attack vector testing
|
||||||
|
- [x] Playwright E2E framework setup
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
- [x] Runtime environment variables (Docker-friendly configuration)
|
- [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
|
## Planned Features
|
||||||
|
|
||||||
### Address Book (Phase 2)
|
|
||||||
- [ ] Contact groups/lists management
|
|
||||||
- [ ] vCard import/export
|
|
||||||
- [ ] Bulk contact operations
|
|
||||||
|
|
||||||
### Advanced Features
|
### Advanced Features
|
||||||
- [ ] Email filters and rules
|
- [ ] Email filters and rules
|
||||||
- [ ] Calendar integration (JMAP Calendars)
|
- [ ] Calendar integration (JMAP Calendars)
|
||||||
- [ ] Email templates
|
- [ ] Email templates
|
||||||
- [ ] Vacation responder settings
|
|
||||||
- [ ] Advanced search with filters
|
|
||||||
- [ ] Email encryption (PGP/GPG)
|
- [ ] Email encryption (PGP/GPG)
|
||||||
|
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
|
||||||
|
|
||||||
### Performance Optimizations
|
### Performance Optimizations
|
||||||
- [ ] Virtual scrolling for large lists
|
|
||||||
- [ ] Email content caching
|
- [ ] Email content caching
|
||||||
- [ ] Bundle size optimization
|
- [ ] Bundle size optimization
|
||||||
- [ ] Service worker for offline support
|
- [ ] Service worker for offline support
|
||||||
- [ ] Lazy loading for attachments
|
- [ ] Lazy loading for attachments
|
||||||
|
|
||||||
### Testing (Remaining)
|
### Testing (Remaining)
|
||||||
- [ ] Component tests
|
- [ ] E2E tests with real JMAP server
|
||||||
- [ ] E2E tests with Playwright
|
|
||||||
- [ ] Accessibility testing
|
- [ ] Accessibility testing
|
||||||
- [ ] Performance testing
|
- [ ] Performance testing
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
- [x] Health check endpoint
|
|
||||||
- [ ] Docker containerization
|
|
||||||
- [ ] Production build optimizations
|
- [ ] Production build optimizations
|
||||||
- [ ] Monitoring and logging
|
- [ ] Monitoring and logging
|
||||||
|
|
||||||
### Security Enhancements
|
### Security Enhancements
|
||||||
- [ ] CSP headers configuration
|
|
||||||
- [ ] Additional XSS protection layers
|
|
||||||
- [ ] Rate limiting
|
- [ ] Rate limiting
|
||||||
- [ ] CORS configuration
|
- [ ] CORS configuration
|
||||||
|
|
||||||
|
|||||||
+383
-39
@@ -1,19 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ContactList } from "@/components/contacts/contact-list";
|
import { ContactList } from "@/components/contacts/contact-list";
|
||||||
import { ContactDetail } from "@/components/contacts/contact-detail";
|
import { ContactDetail } from "@/components/contacts/contact-detail";
|
||||||
import { ContactForm } from "@/components/contacts/contact-form";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
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() {
|
export default function ContactsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -24,8 +39,11 @@ export default function ContactsPage() {
|
|||||||
selectedContactId,
|
selectedContactId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
|
activeTab,
|
||||||
|
selectedContactIds,
|
||||||
setSelectedContact,
|
setSelectedContact,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
setActiveTab,
|
||||||
fetchContacts,
|
fetchContacts,
|
||||||
createContact,
|
createContact,
|
||||||
updateContact,
|
updateContact,
|
||||||
@@ -33,9 +51,24 @@ export default function ContactsPage() {
|
|||||||
addLocalContact,
|
addLocalContact,
|
||||||
updateLocalContact,
|
updateLocalContact,
|
||||||
deleteLocalContact,
|
deleteLocalContact,
|
||||||
|
getGroups,
|
||||||
|
getIndividuals,
|
||||||
|
getGroupMembers,
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
addMembersToGroup,
|
||||||
|
removeMembersFromGroup,
|
||||||
|
deleteGroup,
|
||||||
|
toggleContactSelection,
|
||||||
|
selectAllContacts,
|
||||||
|
clearSelection,
|
||||||
|
bulkDeleteContacts,
|
||||||
|
bulkAddToGroup,
|
||||||
|
importContacts,
|
||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,10 +84,15 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => getGroups(), [contacts]);
|
||||||
|
const individuals = useMemo(() => getIndividuals(), [contacts]);
|
||||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
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) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
|
clearSelection();
|
||||||
setView("detail");
|
setView("detail");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +117,8 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
toast.success(t("toast.deleted"));
|
toast.success(t("toast.deleted"));
|
||||||
setView("list");
|
setView("list");
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error('Failed to delete contact:', error);
|
||||||
toast.error(t("toast.error_delete"));
|
toast.error(t("toast.error_delete"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -114,57 +153,362 @@ export default function ContactsPage() {
|
|||||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
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;
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
return (
|
const renderRightPanel = () => {
|
||||||
<div className="flex h-screen bg-background">
|
switch (view) {
|
||||||
<div className="w-80 border-r border-border flex flex-col">
|
case "create":
|
||||||
<div className="p-4 border-b border-border">
|
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
<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>
|
|
||||||
|
|
||||||
<ContactList
|
case "edit":
|
||||||
contacts={contacts}
|
if (!selectedContact) return null;
|
||||||
selectedContactId={selectedContactId}
|
return (
|
||||||
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 && (
|
|
||||||
<ContactForm
|
<ContactForm
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
onCancel={handleCancel}
|
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
|
<ContactDetail
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
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>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
{renderRightPanel()}
|
||||||
|
</div>
|
||||||
</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 { notFound } from "next/navigation";
|
||||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
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({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
// Validate that the incoming `locale` parameter is valid
|
|
||||||
if (!(locales as readonly string[]).includes(locale)) notFound();
|
if (!(locales as readonly string[]).includes(locale)) notFound();
|
||||||
|
|
||||||
// Load messages for the current locale
|
|
||||||
let messages;
|
let messages;
|
||||||
try {
|
try {
|
||||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||||
@@ -42,36 +22,10 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<head>
|
<ThemeProvider>
|
||||||
<script
|
{children}
|
||||||
dangerouslySetInnerHTML={{
|
</ThemeProvider>
|
||||||
__html: `
|
</IntlProvider>
|
||||||
(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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
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() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,6 +20,8 @@ export default function LoginPage() {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
const [showTotpField, setShowTotpField] = useState(false);
|
||||||
|
const [totpCode, setTotpCode] = useState("");
|
||||||
|
|
||||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
@@ -223,7 +225,8 @@ export default function LoginPage() {
|
|||||||
const success = await login(
|
const success = await login(
|
||||||
serverUrl,
|
serverUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
formData.password
|
formData.password,
|
||||||
|
showTotpField && totpCode ? totpCode : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
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">
|
<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" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -315,6 +320,34 @@ export default function LoginPage() {
|
|||||||
required
|
required
|
||||||
autoComplete="current-password"
|
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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+32
-1
@@ -27,6 +27,8 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
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() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -77,6 +79,12 @@ export default function Home() {
|
|||||||
clearNewEmailNotification,
|
clearNewEmailNotification,
|
||||||
markAsSpam,
|
markAsSpam,
|
||||||
undoSpam,
|
undoSpam,
|
||||||
|
searchFilters,
|
||||||
|
isAdvancedSearchOpen,
|
||||||
|
setSearchFilters,
|
||||||
|
clearSearchFilters,
|
||||||
|
toggleAdvancedSearch,
|
||||||
|
advancedSearch,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
// Play notification sound for new emails
|
// Play notification sound for new emails
|
||||||
@@ -575,16 +583,27 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
await searchEmails(client, query);
|
setSearchQuery(query);
|
||||||
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
|
await advancedSearch(client);
|
||||||
|
} else {
|
||||||
|
await searchEmails(client, query);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearSearch = async () => {
|
const handleClearSearch = async () => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
clearSearchFilters();
|
||||||
if (client && selectedMailbox) {
|
if (client && selectedMailbox) {
|
||||||
await fetchEmails(client, selectedMailbox);
|
await fetchEmails(client, selectedMailbox);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdvancedSearch = async () => {
|
||||||
|
if (!client) return;
|
||||||
|
await advancedSearch(client);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||||
if (!client) return;
|
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}>
|
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||||
<EmailList
|
<EmailList
|
||||||
emails={emails}
|
emails={emails}
|
||||||
|
|||||||
@@ -9,21 +9,27 @@ import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
|||||||
import { EmailSettings } from '@/components/settings/email-settings';
|
import { EmailSettings } from '@/components/settings/email-settings';
|
||||||
import { AccountSettings } from '@/components/settings/account-settings';
|
import { AccountSettings } from '@/components/settings/account-settings';
|
||||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||||
|
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'advanced';
|
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'advanced';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations('settings');
|
const t = useTranslations('settings');
|
||||||
|
const { client } = useAuthStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||||
|
|
||||||
|
const supportsVacation = client?.supportsVacationResponse() ?? false;
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
{ id: 'appearance', label: t('tabs.appearance') },
|
{ id: 'appearance', label: t('tabs.appearance') },
|
||||||
{ id: 'email', label: t('tabs.email') },
|
{ id: 'email', label: t('tabs.email') },
|
||||||
{ id: 'account', label: t('tabs.account') },
|
{ id: 'account', label: t('tabs.account') },
|
||||||
{ id: 'identities', label: t('tabs.identities') },
|
{ id: 'identities', label: t('tabs.identities') },
|
||||||
|
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
|
||||||
{ id: 'advanced', label: t('tabs.advanced') },
|
{ id: 'advanced', label: t('tabs.advanced') },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -82,6 +88,7 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'email' && <EmailSettings />}
|
{activeTab === 'email' && <EmailSettings />}
|
||||||
{activeTab === 'account' && <AccountSettings />}
|
{activeTab === 'account' && <AccountSettings />}
|
||||||
{activeTab === 'identities' && <IdentitySettings />}
|
{activeTab === 'identities' && <IdentitySettings />}
|
||||||
|
{activeTab === 'vacation' && <VacationSettings />}
|
||||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -13,6 +14,7 @@ import { NextResponse } from 'next/server';
|
|||||||
* 3. Default values
|
* 3. Default values
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
logger.debug('Config requested');
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
|
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 || '',
|
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Health check thresholds
|
// Health check thresholds
|
||||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
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, {
|
return NextResponse.json(response, {
|
||||||
status: httpStatus,
|
status: httpStatus,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -96,6 +99,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Health check failed', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
|
|||||||
+57
-7
@@ -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 = {
|
const geistSans = Geist({
|
||||||
children: ReactNode;
|
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
|
export default async function RootLayout({
|
||||||
// The actual layout with providers and styles is in [locale]/layout.tsx
|
children,
|
||||||
export default function RootLayout({ children }: Props) {
|
}: {
|
||||||
return 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 { useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContactListItem } from "./contact-list-item";
|
import { ContactListItem } from "./contact-list-item";
|
||||||
@@ -19,6 +19,13 @@ interface ContactListProps {
|
|||||||
onCreateNew: () => void;
|
onCreateNew: () => void;
|
||||||
supportsSync: boolean;
|
supportsSync: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
selectedContactIds: Set<string>;
|
||||||
|
onToggleSelection: (id: string) => void;
|
||||||
|
onSelectAll: (ids: string[]) => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onBulkDelete: () => void;
|
||||||
|
onBulkAddToGroup: () => void;
|
||||||
|
onBulkExport: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactList({
|
export function ContactList({
|
||||||
@@ -30,13 +37,21 @@ export function ContactList({
|
|||||||
onCreateNew,
|
onCreateNew,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
className,
|
className,
|
||||||
|
selectedContactIds,
|
||||||
|
onToggleSelection,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelection,
|
||||||
|
onBulkDelete,
|
||||||
|
onBulkAddToGroup,
|
||||||
|
onBulkExport,
|
||||||
}: ContactListProps) {
|
}: ContactListProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchQuery) return contacts;
|
const individuals = contacts.filter(c => c.kind !== "group");
|
||||||
|
if (!searchQuery) return individuals;
|
||||||
const lower = searchQuery.toLowerCase();
|
const lower = searchQuery.toLowerCase();
|
||||||
return contacts.filter((c) => {
|
return individuals.filter((c) => {
|
||||||
const name = getContactDisplayName(c).toLowerCase();
|
const name = getContactDisplayName(c).toLowerCase();
|
||||||
const emails = c.emails
|
const emails = c.emails
|
||||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
||||||
@@ -55,6 +70,9 @@ export function ContactList({
|
|||||||
});
|
});
|
||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
|
|
||||||
|
const hasSelection = selectedContactIds.size > 0;
|
||||||
|
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full", className)}>
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
<div className="px-4 py-3 border-b border-border space-y-3">
|
<div className="px-4 py-3 border-b border-border space-y-3">
|
||||||
@@ -84,6 +102,60 @@ export function ContactList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground px-4">
|
<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">
|
<div className="divide-y divide-border">
|
||||||
{sorted.map((contact) => (
|
{sorted.map((contact) => (
|
||||||
<ContactListItem
|
<div key={contact.id} className="flex items-center">
|
||||||
key={contact.id}
|
<button
|
||||||
contact={contact}
|
onClick={(e) => {
|
||||||
isSelected={contact.id === selectedContactId}
|
e.stopPropagation();
|
||||||
onClick={() => onSelectContact(contact.id)}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+112
-53
@@ -9,9 +9,13 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { useTranslations } from "next-intl";
|
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 {
|
interface EmailListProps {
|
||||||
emails: Email[];
|
emails: Email[];
|
||||||
@@ -19,9 +23,7 @@ interface EmailListProps {
|
|||||||
onEmailSelect?: (email: Email) => void;
|
onEmailSelect?: (email: Email) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
// Mobile conversation view handler
|
|
||||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||||
// Context menu actions
|
|
||||||
onReply?: (email: Email) => void;
|
onReply?: (email: Email) => void;
|
||||||
onReplyAll?: (email: Email) => void;
|
onReplyAll?: (email: Email) => void;
|
||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
@@ -76,20 +78,37 @@ export function EmailList({
|
|||||||
isLoadingThread,
|
isLoadingThread,
|
||||||
toggleThreadExpansion,
|
toggleThreadExpansion,
|
||||||
fetchThreadEmails,
|
fetchThreadEmails,
|
||||||
|
searchFilters,
|
||||||
|
setSearchFilters,
|
||||||
|
clearSearchFilters,
|
||||||
|
advancedSearch,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
// Group emails by thread
|
|
||||||
const threadGroups = useMemo(() => {
|
const threadGroups = useMemo(() => {
|
||||||
const groups = groupEmailsByThread(emails);
|
const groups = groupEmailsByThread(emails);
|
||||||
return sortThreadGroups(groups);
|
return sortThreadGroups(groups);
|
||||||
}, [emails]);
|
}, [emails]);
|
||||||
|
|
||||||
// Context menu state
|
|
||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||||
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
// Loading skeleton component - gentler, no pulsing
|
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 = () => (
|
const LoadingSkeleton = () => (
|
||||||
<div className="animate-in fade-in duration-200">
|
<div className="animate-in fade-in duration-200">
|
||||||
{[...Array(8)].map((_, i) => (
|
{[...Array(8)].map((_, i) => (
|
||||||
@@ -119,7 +138,7 @@ export function EmailList({
|
|||||||
try {
|
try {
|
||||||
await batchMarkAsRead(client, read);
|
await batchMarkAsRead(client, read);
|
||||||
} finally {
|
} 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(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||||
loadMoreEmails(client);
|
loadMoreEmails(client);
|
||||||
}
|
}
|
||||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||||
|
|
||||||
// Handle thread expansion and fetch complete thread
|
|
||||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||||
const isExpanded = expandedThreadIds.has(threadId);
|
const isExpanded = expandedThreadIds.has(threadId);
|
||||||
|
|
||||||
if (!isExpanded && client) {
|
if (!isExpanded && client) {
|
||||||
// Expanding - fetch complete thread emails
|
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
await fetchThreadEmails(client, threadId);
|
await fetchThreadEmails(client, threadId);
|
||||||
} else {
|
} else {
|
||||||
// Collapsing - just toggle
|
|
||||||
toggleThreadExpansion(threadId);
|
toggleThreadExpansion(threadId);
|
||||||
}
|
}
|
||||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
if (lastVirtualItemIndex === undefined) return;
|
||||||
(entries) => {
|
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||||
if (entries[0].isIntersecting) {
|
handleLoadMore();
|
||||||
handleLoadMore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentTarget = observerTarget.current;
|
|
||||||
if (currentTarget) {
|
|
||||||
observer.observe(currentTarget);
|
|
||||||
}
|
}
|
||||||
|
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||||
|
|
||||||
return () => {
|
// Scroll to the thread group containing the selected email
|
||||||
if (currentTarget) {
|
useEffect(() => {
|
||||||
observer.unobserve(currentTarget);
|
if (!selectedEmailId) return;
|
||||||
}
|
const index = threadGroups.findIndex(thread =>
|
||||||
};
|
thread.latestEmail.id === selectedEmailId ||
|
||||||
}, [handleLoadMore]);
|
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 (
|
return (
|
||||||
<div className={cn("flex flex-col h-full", className)}>
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
{/* Batch Actions Toolbar with smooth transition */}
|
{/* Batch Actions Toolbar */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
@@ -249,6 +272,22 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* List Header */}
|
||||||
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
@@ -281,8 +320,8 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email List */}
|
{/* Email List */}
|
||||||
<div className="flex-1 overflow-y-auto bg-background relative">
|
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
|
||||||
{/* Loading overlay - shows on top of existing emails */}
|
{/* Loading overlay */}
|
||||||
{isLoading && emails.length > 0 && (
|
{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="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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show skeleton only on initial load (no emails yet) */}
|
|
||||||
{isLoading && emails.length === 0 ? (
|
{isLoading && emails.length === 0 ? (
|
||||||
<LoadingSkeleton />
|
<LoadingSkeleton />
|
||||||
) : emails.length === 0 && !isLoading ? (
|
) : 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>
|
<p className="text-sm mt-1 text-muted-foreground">{t('no_emails_description')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
|
<>
|
||||||
{threadGroups.map((thread) => (
|
<div
|
||||||
<ThreadListItem
|
className={cn("transition-opacity duration-200", isLoading && "opacity-50")}
|
||||||
key={thread.threadId}
|
style={{
|
||||||
thread={thread}
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
isExpanded={expandedThreadIds.has(thread.threadId)}
|
width: '100%',
|
||||||
selectedEmailId={selectedEmailId}
|
position: 'relative',
|
||||||
isLoading={isLoadingThread === thread.threadId}
|
}}
|
||||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
>
|
||||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
const thread = threadGroups[virtualItem.index];
|
||||||
onContextMenu={openContextMenu}
|
return (
|
||||||
onOpenConversation={onOpenConversation}
|
<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 className="py-4 flex justify-center">
|
||||||
<div ref={observerTarget} className="py-4 flex justify-center">
|
|
||||||
{isLoadingMore && hasMoreEmails && (
|
{isLoadingMore && hasMoreEmails && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
@@ -332,7 +393,7 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -349,7 +410,6 @@ export function EmailList({
|
|||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||||
selectedCount={selectedEmailIds.size}
|
selectedCount={selectedEmailIds.size}
|
||||||
// Single email actions
|
|
||||||
onReply={() => onReply?.(contextMenu.data!)}
|
onReply={() => onReply?.(contextMenu.data!)}
|
||||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenu.data!)}
|
||||||
@@ -361,7 +421,6 @@ export function EmailList({
|
|||||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||||
// Batch actions
|
|
||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -16,14 +17,13 @@ interface ThreadListItemProps {
|
|||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
selectedEmailId?: string;
|
selectedEmailId?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
expandedEmails?: Email[]; // Full thread emails when expanded
|
expandedEmails?: Email[];
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onEmailSelect: (email: Email) => void;
|
onEmailSelect: (email: Email) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, 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 = {
|
const colorTags = {
|
||||||
red: "bg-red-50 dark:bg-red-950/30",
|
red: "bg-red-50 dark:bg-red-950/30",
|
||||||
orange: "bg-orange-50 dark:bg-orange-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",
|
pink: "bg-pink-50 dark:bg-pink-950/30",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function ThreadListItem({
|
interface SingleEmailItemProps {
|
||||||
thread,
|
email: Email;
|
||||||
isExpanded,
|
selected: boolean;
|
||||||
selectedEmailId,
|
onClick: () => void;
|
||||||
isLoading = false,
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
expandedEmails,
|
showPreview: boolean;
|
||||||
onToggleExpand,
|
colorTag: string | null;
|
||||||
onEmailSelect,
|
}
|
||||||
onContextMenu,
|
|
||||||
onOpenConversation,
|
|
||||||
}: ThreadListItemProps) {
|
|
||||||
const t = useTranslations('threads');
|
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
|
||||||
|
|
||||||
// Get color tag from thread
|
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
|
||||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
const isUnread = !email.keywords?.$seen;
|
||||||
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const sender = email.from?.[0];
|
||||||
|
|
||||||
// Check if latest email is selected
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
onContextMenu?.(e, email);
|
||||||
thread.emails.some(e => e.id === selectedEmailId);
|
};
|
||||||
|
|
||||||
// Single email thread - render as regular email, no expand
|
|
||||||
if (emailCount === 1) {
|
|
||||||
return (
|
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
|
<div
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer transition-all duration-200",
|
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
isSelected
|
selected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
isSelected && !colorTag && "shadow-sm",
|
selected && !colorTag && "shadow-sm",
|
||||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
isUnread && !colorTag && "bg-accent/30"
|
||||||
isExpanded && "border-b border-border/50"
|
|
||||||
)}
|
)}
|
||||||
onClick={handleHeaderClick}
|
onClick={onClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
@@ -125,257 +76,281 @@ export function ThreadListItem({
|
|||||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||||
}}>
|
}}>
|
||||||
{/* Expand/Collapse Button - Hidden on mobile */}
|
<div className="w-6 flex-shrink-0" />
|
||||||
{!isMobile && (
|
|
||||||
<button
|
|
||||||
data-expand-toggle
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleExpand();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
|
||||||
"hover:bg-muted/50 hover:scale-110",
|
|
||||||
"active:scale-95",
|
|
||||||
"text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Unread indicator */}
|
{isUnread && (
|
||||||
{hasUnread && (
|
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
<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" />
|
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
<Avatar
|
<Avatar
|
||||||
name={latestEmail.from?.[0]?.name}
|
name={sender?.name}
|
||||||
email={latestEmail.from?.[0]?.email}
|
email={sender?.email}
|
||||||
size="md"
|
size="md"
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<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 justify-between gap-2 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-sm",
|
"truncate text-sm",
|
||||||
hasUnread
|
isUnread
|
||||||
? "font-bold text-foreground"
|
? "font-bold text-foreground"
|
||||||
: "font-medium text-muted-foreground"
|
: "font-medium text-muted-foreground"
|
||||||
)}>
|
)}>
|
||||||
{participantNames.join(", ")}
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
</span>
|
|
||||||
{/* Email count badge */}
|
|
||||||
<span className={cn(
|
|
||||||
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
|
||||||
hasUnread
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{emailCount}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{hasStarred && (
|
{isStarred && (
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
<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" />
|
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs flex-shrink-0 tabular-nums",
|
"text-xs flex-shrink-0 tabular-nums",
|
||||||
hasUnread
|
isUnread
|
||||||
? "text-foreground font-semibold"
|
? "text-foreground font-semibold"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
)}>
|
)}>
|
||||||
{formatDate(latestEmail.receivedAt)}
|
{formatDate(email.receivedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Second Line: Subject */}
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mb-1 line-clamp-1 text-sm",
|
"mb-1 line-clamp-1 text-sm",
|
||||||
hasUnread
|
isUnread
|
||||||
? "font-semibold text-foreground"
|
? "font-semibold text-foreground"
|
||||||
: "font-normal text-foreground/90"
|
: "font-normal text-foreground/90"
|
||||||
)}>
|
)}>
|
||||||
{latestEmail.subject || "(no subject)"}
|
{email.subject || "(no subject)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Third Line: Preview */}
|
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
"text-sm leading-relaxed line-clamp-2",
|
"text-sm leading-relaxed line-clamp-2",
|
||||||
hasUnread
|
isUnread
|
||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: "text-muted-foreground/80"
|
: "text-muted-foreground/80"
|
||||||
)}>
|
)}>
|
||||||
{latestEmail.preview || "No preview available"}
|
{email.preview || "No preview available"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
{/* Expanded Thread Emails - Desktop only */}
|
export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemProps>(
|
||||||
{isExpanded && !isMobile && (
|
function ThreadListItem({
|
||||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
thread,
|
||||||
{isLoading ? (
|
isExpanded,
|
||||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
selectedEmailId,
|
||||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
isLoading = false,
|
||||||
{t('loading')}
|
expandedEmails,
|
||||||
</div>
|
onToggleExpand,
|
||||||
) : (
|
onEmailSelect,
|
||||||
emailsToShow.map((email, index) => (
|
onContextMenu,
|
||||||
<ThreadEmailItem
|
onOpenConversation,
|
||||||
key={email.id}
|
}, ref) {
|
||||||
email={email}
|
const t = useTranslations('threads');
|
||||||
selected={email.id === selectedEmailId}
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
isLast={index === emailsToShow.length - 1}
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
onClick={() => onEmailSelect(email)}
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
/>
|
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>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single email item (for threads with only 1 email)
|
{isExpanded && !isMobile && (
|
||||||
function SingleEmailItem({
|
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||||
email,
|
{isLoading ? (
|
||||||
selected,
|
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
onClick,
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
onContextMenu,
|
{t('loading')}
|
||||||
showPreview,
|
</div>
|
||||||
colorTag,
|
) : (
|
||||||
}: {
|
emailsToShow.map((email, index) => (
|
||||||
email: Email;
|
<ThreadEmailItem
|
||||||
selected: boolean;
|
key={email.id}
|
||||||
onClick: () => void;
|
email={email}
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
selected={email.id === selectedEmailId}
|
||||||
showPreview: boolean;
|
isLast={index === emailsToShow.length - 1}
|
||||||
colorTag: string | null;
|
onClick={() => onEmailSelect(email)}
|
||||||
}) {
|
onContextMenu={onContextMenu}
|
||||||
const isUnread = !email.keywords?.$seen;
|
/>
|
||||||
const isStarred = email.keywords?.$flagged;
|
))
|
||||||
const sender = email.from?.[0];
|
)}
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
|
||||||
onContextMenu?.(e, email);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
|
||||||
colorTag ? colorTag : (
|
|
||||||
selected
|
|
||||||
? "bg-accent"
|
|
||||||
: "bg-background"
|
|
||||||
),
|
|
||||||
selected && !colorTag && "shadow-sm",
|
|
||||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
|
||||||
isUnread && !colorTag && "bg-accent/30"
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3 px-4" style={{
|
|
||||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
|
||||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
|
||||||
}}>
|
|
||||||
{/* Spacer for alignment with thread items */}
|
|
||||||
<div className="w-6 flex-shrink-0" />
|
|
||||||
|
|
||||||
{/* Unread indicator */}
|
|
||||||
{isUnread && (
|
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
|
||||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
);
|
||||||
|
|||||||
@@ -25,12 +25,17 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
User,
|
User,
|
||||||
BookUser,
|
BookUser,
|
||||||
|
Palmtree,
|
||||||
|
SlidersHorizontal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
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";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface SidebarProps {
|
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({
|
export function Sidebar({
|
||||||
mailboxes = [],
|
mailboxes = [],
|
||||||
selectedMailbox = "",
|
selectedMailbox = "",
|
||||||
@@ -369,33 +425,36 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search + Advanced Filter Toggle */}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<form onSubmit={handleSearch} className="relative">
|
<div className="flex items-center gap-1.5">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<form onSubmit={handleSearch} className="relative flex-1">
|
||||||
<Input
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
type="text"
|
<Input
|
||||||
placeholder={t("search_placeholder")}
|
type="text"
|
||||||
value={searchQuery}
|
placeholder={t("search_placeholder")}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
className={cn("pl-9", searchQuery && "pr-8")}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
data-search-input
|
className={cn("pl-9", searchQuery && "pr-8")}
|
||||||
/>
|
data-search-input
|
||||||
{searchQuery && (
|
/>
|
||||||
<button
|
{searchQuery && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setSearchQuery("");
|
onClick={() => {
|
||||||
onClearSearch?.();
|
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')}
|
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>
|
<X className="w-4 h-4" />
|
||||||
)}
|
</button>
|
||||||
</form>
|
)}
|
||||||
|
</form>
|
||||||
|
<AdvancedSearchToggle />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -505,6 +564,7 @@ export function Sidebar({
|
|||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Menu className="w-4 h-4" />
|
<Menu className="w-4 h-4" />
|
||||||
Menu
|
Menu
|
||||||
|
<VacationIndicator />
|
||||||
{/* Push Connection Status Indicator */}
|
{/* Push Connection Status Indicator */}
|
||||||
<span
|
<span
|
||||||
className="relative group"
|
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: [
|
ignores: [
|
||||||
".next/**",
|
".next/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"*.config.js",
|
"*.config.js",
|
||||||
"*.config.mjs",
|
"*.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
|
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||||
interface JMAPSession {
|
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
|
// Thread methods for conversation view
|
||||||
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
|
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
|
||||||
try {
|
try {
|
||||||
@@ -1090,6 +1145,60 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
|
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(
|
async createDraft(
|
||||||
to: string[],
|
to: string[],
|
||||||
subject: 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>;
|
addresses?: Record<string, ContactAddress>;
|
||||||
nicknames?: Record<string, ContactNickname>;
|
nicknames?: Record<string, ContactNickname>;
|
||||||
notes?: Record<string, ContactNote>;
|
notes?: Record<string, ContactNote>;
|
||||||
|
members?: Record<string, boolean>;
|
||||||
created?: string;
|
created?: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
}
|
}
|
||||||
@@ -233,6 +234,16 @@ export interface AddressBookRights {
|
|||||||
mayDelete: boolean;
|
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 {
|
export interface EmailSubmission {
|
||||||
id: string;
|
id: string;
|
||||||
identityId: 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": {
|
"error": {
|
||||||
"invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort",
|
"invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort",
|
||||||
"connection_failed": "Verbindung zum Server fehlgeschlagen",
|
"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": {
|
"config_error": {
|
||||||
"title": "Konfigurationsfehler",
|
"title": "Konfigurationsfehler",
|
||||||
"fetch_failed": "Die Anwendungskonfiguration kann nicht geladen werden. Bitte versuchen Sie es später erneut.",
|
"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."
|
"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": {
|
"sidebar": {
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "Verfassen",
|
"compose": "Verfassen",
|
||||||
"go_back": "Zurück"
|
"go_back": "Zurück"
|
||||||
},
|
},
|
||||||
"clear_search": "Suche löschen"
|
"clear_search": "Suche löschen",
|
||||||
|
"vacation_active": "Abwesenheitsnotiz ist aktiv"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "Keine E-Mails",
|
"no_emails": "Keine E-Mails",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {{error}}",
|
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {{error}}",
|
||||||
"identity_delete_failed": "Identität löschen 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_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": {
|
"date": {
|
||||||
"today": "Heute",
|
"today": "Heute",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "Datenschutz & Sicherheit",
|
"privacy": "Datenschutz & Sicherheit",
|
||||||
"account": "Konto",
|
"account": "Konto",
|
||||||
"identities": "Identitäten",
|
"identities": "Identitäten",
|
||||||
|
"vacation": "Abwesenheitsnotiz",
|
||||||
"advanced": "Erweitert"
|
"advanced": "Erweitert"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "Mehr erfahren"
|
"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": {
|
"advanced": {
|
||||||
"title": "Erweitert",
|
"title": "Erweitert",
|
||||||
"description": "Erweiterte Optionen und Entwicklereinstellungen",
|
"description": "Erweiterte Optionen und Entwicklereinstellungen",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
|
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
|
||||||
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
|
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
|
||||||
"back_to_mail": "Zurück zur E-Mail",
|
"back_to_mail": "Zurück zur E-Mail",
|
||||||
|
"tabs": {
|
||||||
|
"all": "Alle",
|
||||||
|
"groups": "Gruppen"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-Mail-Adressen",
|
"emails": "E-Mail-Adressen",
|
||||||
"phones": "Telefonnummern",
|
"phones": "Telefonnummern",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||||
"save_failed": "Kontakt konnte nicht gespeichert werden"
|
"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": {
|
"toast": {
|
||||||
"created": "Kontakt erstellt",
|
"created": "Kontakt erstellt",
|
||||||
"updated": "Kontakt aktualisiert",
|
"updated": "Kontakt aktualisiert",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "Kontakt konnte nicht aktualisiert werden",
|
"error_update": "Kontakt konnte nicht aktualisiert werden",
|
||||||
"error_delete": "Kontakt konnte nicht gelöscht werden"
|
"error_delete": "Kontakt konnte nicht gelöscht werden"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"advanced_search": {
|
||||||
|
"title": "Erweiterte Suche",
|
||||||
|
"from": "Von",
|
||||||
|
"from_placeholder": "E-Mail oder Name des Absenders",
|
||||||
|
"to": "An",
|
||||||
|
"to_placeholder": "E-Mail oder Name des Empfängers",
|
||||||
|
"subject": "Betreff",
|
||||||
|
"subject_placeholder": "Betreff enthält...",
|
||||||
|
"body": "Nachricht",
|
||||||
|
"has_attachment": "Anhänge",
|
||||||
|
"date_after": "Nach dem",
|
||||||
|
"date_before": "Vor dem",
|
||||||
|
"starred": "Markiert",
|
||||||
|
"unread": "Ungelesen",
|
||||||
|
"read": "Gelesen",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein",
|
||||||
|
"clear": "Löschen",
|
||||||
|
"clear_all": "Alle löschen",
|
||||||
|
"filters_active": "{count} Filter",
|
||||||
|
"filters_active_plural": "{count} Filter",
|
||||||
|
"toggle_filters": "Filter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-4
@@ -11,14 +11,19 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalid_credentials": "Invalid email or password",
|
"invalid_credentials": "Invalid email or password",
|
||||||
"connection_failed": "Failed to connect to the server",
|
"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": {
|
"config_error": {
|
||||||
"title": "Configuration Error",
|
"title": "Configuration Error",
|
||||||
"fetch_failed": "Unable to load application configuration. Please try again later.",
|
"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."
|
"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": {
|
"sidebar": {
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "Compose",
|
"compose": "Compose",
|
||||||
"go_back": "Go back"
|
"go_back": "Go back"
|
||||||
},
|
},
|
||||||
"clear_search": "Clear search"
|
"clear_search": "Clear search",
|
||||||
|
"vacation_active": "Vacation responder is active"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "No emails",
|
"no_emails": "No emails",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "Failed to update identity: {{error}}",
|
"identity_update_failed": "Failed to update identity: {{error}}",
|
||||||
"identity_delete_failed": "Failed to delete identity: {{error}}",
|
"identity_delete_failed": "Failed to delete identity: {{error}}",
|
||||||
"identity_unauthorized": "You are not authorized to send from this email address",
|
"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": {
|
"date": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "Privacy & Security",
|
"privacy": "Privacy & Security",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"identities": "Identities",
|
"identities": "Identities",
|
||||||
|
"vacation": "Vacation Responder",
|
||||||
"advanced": "Advanced"
|
"advanced": "Advanced"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "Learn More"
|
"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": {
|
"advanced": {
|
||||||
"title": "Advanced",
|
"title": "Advanced",
|
||||||
"description": "Advanced options and developer settings",
|
"description": "Advanced options and developer settings",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "Are you sure you want to delete this contact?",
|
"delete_confirm": "Are you sure you want to delete this contact?",
|
||||||
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
|
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
|
||||||
"back_to_mail": "Back to mail",
|
"back_to_mail": "Back to mail",
|
||||||
|
"tabs": {
|
||||||
|
"all": "All",
|
||||||
|
"groups": "Groups"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Email Addresses",
|
"emails": "Email Addresses",
|
||||||
"phones": "Phone Numbers",
|
"phones": "Phone Numbers",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "Please enter a valid email address",
|
"email_invalid": "Please enter a valid email address",
|
||||||
"save_failed": "Failed to save contact"
|
"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": {
|
"toast": {
|
||||||
"created": "Contact created",
|
"created": "Contact created",
|
||||||
"updated": "Contact updated",
|
"updated": "Contact updated",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "Failed to update contact",
|
"error_update": "Failed to update contact",
|
||||||
"error_delete": "Failed to delete 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": {
|
"error": {
|
||||||
"invalid_credentials": "Correo electrónico o contraseña inválidos",
|
"invalid_credentials": "Correo electrónico o contraseña inválidos",
|
||||||
"connection_failed": "No se pudo conectar con el servidor",
|
"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": {
|
"config_error": {
|
||||||
"title": "Error de Configuración",
|
"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.",
|
"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."
|
"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": {
|
"sidebar": {
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "Redactar",
|
"compose": "Redactar",
|
||||||
"go_back": "Volver"
|
"go_back": "Volver"
|
||||||
},
|
},
|
||||||
"clear_search": "Limpiar búsqueda"
|
"clear_search": "Limpiar búsqueda",
|
||||||
|
"vacation_active": "Respuesta automática activa"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "Sin correos",
|
"no_emails": "Sin correos",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "Error al actualizar identidad: {{error}}",
|
"identity_update_failed": "Error al actualizar identidad: {{error}}",
|
||||||
"identity_delete_failed": "Error al eliminar identidad: {{error}}",
|
"identity_delete_failed": "Error al eliminar identidad: {{error}}",
|
||||||
"identity_unauthorized": "No está autorizado para enviar desde esta dirección de correo",
|
"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": {
|
"date": {
|
||||||
"today": "Hoy",
|
"today": "Hoy",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "Privacidad y Seguridad",
|
"privacy": "Privacidad y Seguridad",
|
||||||
"account": "Cuenta",
|
"account": "Cuenta",
|
||||||
"identities": "Identidades",
|
"identities": "Identidades",
|
||||||
|
"vacation": "Respuesta automática",
|
||||||
"advanced": "Avanzado"
|
"advanced": "Avanzado"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "Más Información"
|
"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": {
|
"advanced": {
|
||||||
"title": "Avanzado",
|
"title": "Avanzado",
|
||||||
"description": "Opciones avanzadas y configuración de desarrollador",
|
"description": "Opciones avanzadas y configuración de desarrollador",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
|
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
|
||||||
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
|
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
|
||||||
"back_to_mail": "Volver al correo",
|
"back_to_mail": "Volver al correo",
|
||||||
|
"tabs": {
|
||||||
|
"all": "Todos",
|
||||||
|
"groups": "Grupos"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Direcciones de correo",
|
"emails": "Direcciones de correo",
|
||||||
"phones": "Números de teléfono",
|
"phones": "Números de teléfono",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "Introduce una dirección de correo válida",
|
"email_invalid": "Introduce una dirección de correo válida",
|
||||||
"save_failed": "Error al guardar el contacto"
|
"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": {
|
"toast": {
|
||||||
"created": "Contacto creado",
|
"created": "Contacto creado",
|
||||||
"updated": "Contacto actualizado",
|
"updated": "Contacto actualizado",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "Error al actualizar el contacto",
|
"error_update": "Error al actualizar el contacto",
|
||||||
"error_delete": "Error al eliminar el contacto"
|
"error_delete": "Error al eliminar el contacto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"advanced_search": {
|
||||||
|
"title": "Búsqueda avanzada",
|
||||||
|
"from": "De",
|
||||||
|
"from_placeholder": "Email o nombre del remitente",
|
||||||
|
"to": "Para",
|
||||||
|
"to_placeholder": "Email o nombre del destinatario",
|
||||||
|
"subject": "Asunto",
|
||||||
|
"subject_placeholder": "El asunto contiene...",
|
||||||
|
"body": "Cuerpo",
|
||||||
|
"has_attachment": "Adjuntos",
|
||||||
|
"date_after": "Después de",
|
||||||
|
"date_before": "Antes de",
|
||||||
|
"starred": "Destacados",
|
||||||
|
"unread": "No leídos",
|
||||||
|
"read": "Leídos",
|
||||||
|
"yes": "Sí",
|
||||||
|
"no": "No",
|
||||||
|
"clear": "Limpiar",
|
||||||
|
"clear_all": "Limpiar todo",
|
||||||
|
"filters_active": "{count} filtro",
|
||||||
|
"filters_active_plural": "{count} filtros",
|
||||||
|
"toggle_filters": "Filtros"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-4
@@ -11,14 +11,19 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalid_credentials": "Email ou mot de passe invalide",
|
"invalid_credentials": "Email ou mot de passe invalide",
|
||||||
"connection_failed": "Échec de la connexion au serveur",
|
"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": {
|
"config_error": {
|
||||||
"title": "Erreur de configuration",
|
"title": "Erreur de configuration",
|
||||||
"fetch_failed": "Impossible de charger la configuration de l'application. Veuillez réessayer plus tard.",
|
"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."
|
"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": {
|
"sidebar": {
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "Composer",
|
"compose": "Composer",
|
||||||
"go_back": "Retour"
|
"go_back": "Retour"
|
||||||
},
|
},
|
||||||
"clear_search": "Effacer la recherche"
|
"clear_search": "Effacer la recherche",
|
||||||
|
"vacation_active": "Répondeur d'absence activé"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "Aucun email",
|
"no_emails": "Aucun email",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "Échec de la mise à jour de l'identité: {{error}}",
|
"identity_update_failed": "Échec de la mise à jour de l'identité: {{error}}",
|
||||||
"identity_delete_failed": "Échec de la suppression 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_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": {
|
"date": {
|
||||||
"today": "Aujourd'hui",
|
"today": "Aujourd'hui",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "Confidentialité et sécurité",
|
"privacy": "Confidentialité et sécurité",
|
||||||
"account": "Compte",
|
"account": "Compte",
|
||||||
"identities": "Identités",
|
"identities": "Identités",
|
||||||
|
"vacation": "Répondeur d'absence",
|
||||||
"advanced": "Avancé"
|
"advanced": "Avancé"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "En savoir plus"
|
"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": {
|
"advanced": {
|
||||||
"title": "Avancé",
|
"title": "Avancé",
|
||||||
"description": "Options avancées et paramètres développeur",
|
"description": "Options avancées et paramètres développeur",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
|
"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)",
|
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
|
||||||
"back_to_mail": "Retour aux e-mails",
|
"back_to_mail": "Retour aux e-mails",
|
||||||
|
"tabs": {
|
||||||
|
"all": "Tous",
|
||||||
|
"groups": "Groupes"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Adresses e-mail",
|
"emails": "Adresses e-mail",
|
||||||
"phones": "Numéros de téléphone",
|
"phones": "Numéros de téléphone",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "Veuillez saisir une adresse e-mail valide",
|
"email_invalid": "Veuillez saisir une adresse e-mail valide",
|
||||||
"save_failed": "Échec de l'enregistrement du contact"
|
"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": {
|
"toast": {
|
||||||
"created": "Contact créé",
|
"created": "Contact créé",
|
||||||
"updated": "Contact mis à jour",
|
"updated": "Contact mis à jour",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "Échec de la mise à jour du contact",
|
"error_update": "Échec de la mise à jour du contact",
|
||||||
"error_delete": "Échec de la suppression du contact"
|
"error_delete": "Échec de la suppression du contact"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"advanced_search": {
|
||||||
|
"title": "Recherche avancée",
|
||||||
|
"from": "De",
|
||||||
|
"from_placeholder": "Email ou nom de l'expéditeur",
|
||||||
|
"to": "À",
|
||||||
|
"to_placeholder": "Email ou nom du destinataire",
|
||||||
|
"subject": "Objet",
|
||||||
|
"subject_placeholder": "L'objet contient...",
|
||||||
|
"body": "Corps",
|
||||||
|
"has_attachment": "Pièces jointes",
|
||||||
|
"date_after": "Après le",
|
||||||
|
"date_before": "Avant le",
|
||||||
|
"starred": "Suivis",
|
||||||
|
"unread": "Non lus",
|
||||||
|
"read": "Lus",
|
||||||
|
"yes": "Oui",
|
||||||
|
"no": "Non",
|
||||||
|
"clear": "Effacer",
|
||||||
|
"clear_all": "Tout effacer",
|
||||||
|
"filters_active": "{count} filtre",
|
||||||
|
"filters_active_plural": "{count} filtres",
|
||||||
|
"toggle_filters": "Filtres"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+132
-4
@@ -11,14 +11,19 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalid_credentials": "Email o password non valida",
|
"invalid_credentials": "Email o password non valida",
|
||||||
"connection_failed": "Impossibile connettersi al server",
|
"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": {
|
"config_error": {
|
||||||
"title": "Errore di configurazione",
|
"title": "Errore di configurazione",
|
||||||
"fetch_failed": "Impossibile caricare la configurazione dell'applicazione. Riprovare più tardi.",
|
"fetch_failed": "Impossibile caricare la configurazione dell'applicazione. Riprovare più tardi.",
|
||||||
"server_not_configured": "Il server di posta non è stato configurato. Contattare l'amministratore."
|
"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": {
|
"sidebar": {
|
||||||
"close": "Chiudi",
|
"close": "Chiudi",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "Scrivi",
|
"compose": "Scrivi",
|
||||||
"go_back": "Indietro"
|
"go_back": "Indietro"
|
||||||
},
|
},
|
||||||
"clear_search": "Cancella ricerca"
|
"clear_search": "Cancella ricerca",
|
||||||
|
"vacation_active": "Risponditore automatico attivo"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "Nessun messaggio",
|
"no_emails": "Nessun messaggio",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "Impossibile aggiornare l'identità: {{error}}",
|
"identity_update_failed": "Impossibile aggiornare l'identità: {{error}}",
|
||||||
"identity_delete_failed": "Impossibile eliminare l'identità: {{error}}",
|
"identity_delete_failed": "Impossibile eliminare l'identità: {{error}}",
|
||||||
"identity_unauthorized": "Non sei autorizzato a inviare da questo indirizzo email",
|
"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": {
|
"date": {
|
||||||
"today": "Oggi",
|
"today": "Oggi",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "Privacy e sicurezza",
|
"privacy": "Privacy e sicurezza",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"identities": "Identità",
|
"identities": "Identità",
|
||||||
|
"vacation": "Risponditore automatico",
|
||||||
"advanced": "Avanzate"
|
"advanced": "Avanzate"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "Scopri di più"
|
"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": {
|
"advanced": {
|
||||||
"title": "Avanzate",
|
"title": "Avanzate",
|
||||||
"description": "Opzioni avanzate e impostazioni per sviluppatori",
|
"description": "Opzioni avanzate e impostazioni per sviluppatori",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
|
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
|
||||||
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
|
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
|
||||||
"back_to_mail": "Torna alla posta",
|
"back_to_mail": "Torna alla posta",
|
||||||
|
"tabs": {
|
||||||
|
"all": "Tutti",
|
||||||
|
"groups": "Gruppi"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Indirizzi email",
|
"emails": "Indirizzi email",
|
||||||
"phones": "Numeri di telefono",
|
"phones": "Numeri di telefono",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "Inserisci un indirizzo email valido",
|
"email_invalid": "Inserisci un indirizzo email valido",
|
||||||
"save_failed": "Impossibile salvare il contatto"
|
"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": {
|
"toast": {
|
||||||
"created": "Contatto creato",
|
"created": "Contatto creato",
|
||||||
"updated": "Contatto aggiornato",
|
"updated": "Contatto aggiornato",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "Impossibile aggiornare il contatto",
|
"error_update": "Impossibile aggiornare il contatto",
|
||||||
"error_delete": "Impossibile eliminare il contatto"
|
"error_delete": "Impossibile eliminare il contatto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"advanced_search": {
|
||||||
|
"title": "Ricerca avanzata",
|
||||||
|
"from": "Da",
|
||||||
|
"from_placeholder": "Email o nome del mittente",
|
||||||
|
"to": "A",
|
||||||
|
"to_placeholder": "Email o nome del destinatario",
|
||||||
|
"subject": "Oggetto",
|
||||||
|
"subject_placeholder": "L'oggetto contiene...",
|
||||||
|
"body": "Corpo",
|
||||||
|
"has_attachment": "Allegati",
|
||||||
|
"date_after": "Dopo il",
|
||||||
|
"date_before": "Prima del",
|
||||||
|
"starred": "Preferiti",
|
||||||
|
"unread": "Non letti",
|
||||||
|
"read": "Letti",
|
||||||
|
"yes": "Sì",
|
||||||
|
"no": "No",
|
||||||
|
"clear": "Cancella",
|
||||||
|
"clear_all": "Cancella tutto",
|
||||||
|
"filters_active": "{count} filtro",
|
||||||
|
"filters_active_plural": "{count} filtri",
|
||||||
|
"toggle_filters": "Filtri"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+132
-4
@@ -11,14 +11,19 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalid_credentials": "メールアドレスまたはパスワードが無効です",
|
"invalid_credentials": "メールアドレスまたはパスワードが無効です",
|
||||||
"connection_failed": "サーバーへの接続に失敗しました",
|
"connection_failed": "サーバーへの接続に失敗しました",
|
||||||
"generic": "エラーが発生しました。もう一度お試しください。"
|
"generic": "エラーが発生しました。もう一度お試しください。",
|
||||||
|
"totp_invalid": "認証コードが無効です。認証アプリを確認してください。"
|
||||||
},
|
},
|
||||||
"config_error": {
|
"config_error": {
|
||||||
"title": "設定エラー",
|
"title": "設定エラー",
|
||||||
"fetch_failed": "アプリケーション設定を読み込めません。後でもう一度お試しください。",
|
"fetch_failed": "アプリケーション設定を読み込めません。後でもう一度お試しください。",
|
||||||
"server_not_configured": "メールサーバーが設定されていません。管理者にお問い合わせください。"
|
"server_not_configured": "メールサーバーが設定されていません。管理者にお問い合わせください。"
|
||||||
},
|
},
|
||||||
"remove_from_history": "履歴から削除"
|
"remove_from_history": "履歴から削除",
|
||||||
|
"totp_toggle": "二要素認証を使用",
|
||||||
|
"totp_label": "認証コード",
|
||||||
|
"totp_placeholder": "000000",
|
||||||
|
"totp_hide": "二要素認証を非表示"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "作成",
|
"compose": "作成",
|
||||||
"go_back": "戻る"
|
"go_back": "戻る"
|
||||||
},
|
},
|
||||||
"clear_search": "検索をクリア"
|
"clear_search": "検索をクリア",
|
||||||
|
"vacation_active": "不在応答が有効です"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "メールがありません",
|
"no_emails": "メールがありません",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "送信者情報の更新に失敗しました: {{error}}",
|
"identity_update_failed": "送信者情報の更新に失敗しました: {{error}}",
|
||||||
"identity_delete_failed": "送信者情報の削除に失敗しました: {{error}}",
|
"identity_delete_failed": "送信者情報の削除に失敗しました: {{error}}",
|
||||||
"identity_unauthorized": "このメールアドレスからの送信は許可されていません",
|
"identity_unauthorized": "このメールアドレスからの送信は許可されていません",
|
||||||
"identity_not_found": "送信者情報が見つかりません"
|
"identity_not_found": "送信者情報が見つかりません",
|
||||||
|
"vacation_saved": "不在応答の設定を保存しました",
|
||||||
|
"vacation_save_failed": "不在応答の設定の保存に失敗しました"
|
||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "プライバシーとセキュリティ",
|
"privacy": "プライバシーとセキュリティ",
|
||||||
"account": "アカウント",
|
"account": "アカウント",
|
||||||
"identities": "送信者情報",
|
"identities": "送信者情報",
|
||||||
|
"vacation": "不在応答",
|
||||||
"advanced": "詳細設定"
|
"advanced": "詳細設定"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "詳細"
|
"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": {
|
"advanced": {
|
||||||
"title": "詳細設定",
|
"title": "詳細設定",
|
||||||
"description": "詳細オプションと開発者設定",
|
"description": "詳細オプションと開発者設定",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "この連絡先を削除してもよろしいですか?",
|
"delete_confirm": "この連絡先を削除してもよろしいですか?",
|
||||||
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
|
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
|
||||||
"back_to_mail": "メールに戻る",
|
"back_to_mail": "メールに戻る",
|
||||||
|
"tabs": {
|
||||||
|
"all": "すべて",
|
||||||
|
"groups": "グループ"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "メールアドレス",
|
"emails": "メールアドレス",
|
||||||
"phones": "電話番号",
|
"phones": "電話番号",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "有効なメールアドレスを入力してください",
|
"email_invalid": "有効なメールアドレスを入力してください",
|
||||||
"save_failed": "連絡先の保存に失敗しました"
|
"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": {
|
"toast": {
|
||||||
"created": "連絡先を作成しました",
|
"created": "連絡先を作成しました",
|
||||||
"updated": "連絡先を更新しました",
|
"updated": "連絡先を更新しました",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "連絡先の更新に失敗しました",
|
"error_update": "連絡先の更新に失敗しました",
|
||||||
"error_delete": "連絡先の削除に失敗しました"
|
"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": {
|
"error": {
|
||||||
"invalid_credentials": "Ongeldig e-mailadres of wachtwoord",
|
"invalid_credentials": "Ongeldig e-mailadres of wachtwoord",
|
||||||
"connection_failed": "Kan geen verbinding maken met de server",
|
"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": {
|
"config_error": {
|
||||||
"title": "Configuratiefout",
|
"title": "Configuratiefout",
|
||||||
"fetch_failed": "Kan de applicatieconfiguratie niet laden. Probeer het later opnieuw.",
|
"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."
|
"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": {
|
"sidebar": {
|
||||||
"close": "Sluiten",
|
"close": "Sluiten",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "Opstellen",
|
"compose": "Opstellen",
|
||||||
"go_back": "Terug"
|
"go_back": "Terug"
|
||||||
},
|
},
|
||||||
"clear_search": "Zoekopdracht wissen"
|
"clear_search": "Zoekopdracht wissen",
|
||||||
|
"vacation_active": "Afwezigheidsmelder is actief"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "Geen e-mails",
|
"no_emails": "Geen e-mails",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "Kan identiteit niet bijwerken: {{error}}",
|
"identity_update_failed": "Kan identiteit niet bijwerken: {{error}}",
|
||||||
"identity_delete_failed": "Kan identiteit niet verwijderen: {{error}}",
|
"identity_delete_failed": "Kan identiteit niet verwijderen: {{error}}",
|
||||||
"identity_unauthorized": "Je bent niet geautoriseerd om vanaf dit e-mailadres te verzenden",
|
"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": {
|
"date": {
|
||||||
"today": "Vandaag",
|
"today": "Vandaag",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "Privacy & Beveiliging",
|
"privacy": "Privacy & Beveiliging",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"identities": "Identiteiten",
|
"identities": "Identiteiten",
|
||||||
|
"vacation": "Afwezigheidsmelder",
|
||||||
"advanced": "Geavanceerd"
|
"advanced": "Geavanceerd"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "Meer informatie"
|
"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": {
|
"advanced": {
|
||||||
"title": "Geavanceerd",
|
"title": "Geavanceerd",
|
||||||
"description": "Geavanceerde opties en ontwikkelaarsinstellingen",
|
"description": "Geavanceerde opties en ontwikkelaarsinstellingen",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
|
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
|
||||||
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
|
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
|
||||||
"back_to_mail": "Terug naar e-mail",
|
"back_to_mail": "Terug naar e-mail",
|
||||||
|
"tabs": {
|
||||||
|
"all": "Alle",
|
||||||
|
"groups": "Groepen"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "E-mailadressen",
|
"emails": "E-mailadressen",
|
||||||
"phones": "Telefoonnummers",
|
"phones": "Telefoonnummers",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "Voer een geldig e-mailadres in",
|
"email_invalid": "Voer een geldig e-mailadres in",
|
||||||
"save_failed": "Kon contact niet opslaan"
|
"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": {
|
"toast": {
|
||||||
"created": "Contact aangemaakt",
|
"created": "Contact aangemaakt",
|
||||||
"updated": "Contact bijgewerkt",
|
"updated": "Contact bijgewerkt",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "Kon contact niet bijwerken",
|
"error_update": "Kon contact niet bijwerken",
|
||||||
"error_delete": "Kon contact niet verwijderen"
|
"error_delete": "Kon contact niet verwijderen"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"advanced_search": {
|
||||||
|
"title": "Geavanceerd zoeken",
|
||||||
|
"from": "Van",
|
||||||
|
"from_placeholder": "E-mail of naam afzender",
|
||||||
|
"to": "Aan",
|
||||||
|
"to_placeholder": "E-mail of naam ontvanger",
|
||||||
|
"subject": "Onderwerp",
|
||||||
|
"subject_placeholder": "Onderwerp bevat...",
|
||||||
|
"body": "Inhoud",
|
||||||
|
"has_attachment": "Bijlagen",
|
||||||
|
"date_after": "Na",
|
||||||
|
"date_before": "Voor",
|
||||||
|
"starred": "Met ster",
|
||||||
|
"unread": "Ongelezen",
|
||||||
|
"read": "Gelezen",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nee",
|
||||||
|
"clear": "Wissen",
|
||||||
|
"clear_all": "Alles wissen",
|
||||||
|
"filters_active": "{count} filter",
|
||||||
|
"filters_active_plural": "{count} filters",
|
||||||
|
"toggle_filters": "Filters"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-4
@@ -11,14 +11,19 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalid_credentials": "E-mail ou senha inválidos",
|
"invalid_credentials": "E-mail ou senha inválidos",
|
||||||
"connection_failed": "Falha ao conectar com o servidor",
|
"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": {
|
"config_error": {
|
||||||
"title": "Erro de Configuração",
|
"title": "Erro de Configuração",
|
||||||
"fetch_failed": "Não foi possível carregar a configuração do aplicativo. Por favor, tente novamente mais tarde.",
|
"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."
|
"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": {
|
"sidebar": {
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
@@ -59,7 +64,8 @@
|
|||||||
"compose": "Escrever",
|
"compose": "Escrever",
|
||||||
"go_back": "Voltar"
|
"go_back": "Voltar"
|
||||||
},
|
},
|
||||||
"clear_search": "Limpar busca"
|
"clear_search": "Limpar busca",
|
||||||
|
"vacation_active": "Resposta automática ativa"
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
"no_emails": "Nenhum e-mail",
|
"no_emails": "Nenhum e-mail",
|
||||||
@@ -313,7 +319,9 @@
|
|||||||
"identity_update_failed": "Falha ao atualizar identidade: {{error}}",
|
"identity_update_failed": "Falha ao atualizar identidade: {{error}}",
|
||||||
"identity_delete_failed": "Falha ao excluir 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_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": {
|
"date": {
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
@@ -366,6 +374,7 @@
|
|||||||
"privacy": "Privacidade e Segurança",
|
"privacy": "Privacidade e Segurança",
|
||||||
"account": "Conta",
|
"account": "Conta",
|
||||||
"identities": "Identidades",
|
"identities": "Identidades",
|
||||||
|
"vacation": "Resposta automática",
|
||||||
"advanced": "Avançado"
|
"advanced": "Avançado"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -568,6 +577,49 @@
|
|||||||
"learn_more": "Saiba Mais"
|
"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": {
|
"advanced": {
|
||||||
"title": "Avançado",
|
"title": "Avançado",
|
||||||
"description": "Opções avançadas e configurações de desenvolvedor",
|
"description": "Opções avançadas e configurações de desenvolvedor",
|
||||||
@@ -753,6 +805,10 @@
|
|||||||
"delete_confirm": "Tem certeza de que deseja excluir este contato?",
|
"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)",
|
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
|
||||||
"back_to_mail": "Voltar ao e-mail",
|
"back_to_mail": "Voltar ao e-mail",
|
||||||
|
"tabs": {
|
||||||
|
"all": "Todos",
|
||||||
|
"groups": "Grupos"
|
||||||
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"emails": "Endereços de e-mail",
|
"emails": "Endereços de e-mail",
|
||||||
"phones": "Números de telefone",
|
"phones": "Números de telefone",
|
||||||
@@ -788,6 +844,55 @@
|
|||||||
"email_invalid": "Por favor, insira um endereço de e-mail válido",
|
"email_invalid": "Por favor, insira um endereço de e-mail válido",
|
||||||
"save_failed": "Falha ao salvar contato"
|
"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": {
|
"toast": {
|
||||||
"created": "Contato criado",
|
"created": "Contato criado",
|
||||||
"updated": "Contato atualizado",
|
"updated": "Contato atualizado",
|
||||||
@@ -796,5 +901,28 @@
|
|||||||
"error_update": "Falha ao atualizar contato",
|
"error_update": "Falha ao atualizar contato",
|
||||||
"error_delete": "Falha ao excluir 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 type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -2416,6 +2418,23 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -3255,6 +3274,33 @@
|
|||||||
"tailwindcss": "4.1.18"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -8293,6 +8339,53 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/po-parser": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.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 { type NextRequest, NextResponse } from "next/server";
|
||||||
import { routing } from './i18n/routing';
|
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 = {
|
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 { useEmailStore } from './email-store';
|
||||||
import { useIdentityStore } from './identity-store';
|
import { useIdentityStore } from './identity-store';
|
||||||
import { useContactStore } from './contact-store';
|
import { useContactStore } from './contact-store';
|
||||||
|
import { useVacationStore } from './vacation-store';
|
||||||
import type { Identity } from '@/lib/jmap/types';
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -16,7 +17,7 @@ interface AuthState {
|
|||||||
identities: Identity[];
|
identities: Identity[];
|
||||||
primaryIdentity: Identity | null;
|
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;
|
logout: () => void;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
@@ -34,12 +35,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
identities: [],
|
identities: [],
|
||||||
primaryIdentity: null,
|
primaryIdentity: null,
|
||||||
|
|
||||||
login: async (serverUrl, username, password) => {
|
login: async (serverUrl, username, password, totp) => {
|
||||||
|
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create JMAP client
|
// Create JMAP client
|
||||||
const client = new JMAPClient(serverUrl, username, password);
|
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
||||||
|
|
||||||
// Try to connect
|
// Try to connect
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -55,12 +57,21 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (client.supportsContacts()) {
|
if (client.supportsContacts()) {
|
||||||
const contactStore = useContactStore.getState();
|
const contactStore = useContactStore.getState();
|
||||||
contactStore.setSupportsSync(true);
|
contactStore.setSupportsSync(true);
|
||||||
contactStore.fetchAddressBooks(client).catch(() => {});
|
contactStore.fetchAddressBooks(client).catch((err) => console.error('Failed to fetch address books:', err));
|
||||||
contactStore.fetchContacts(client).catch(() => {});
|
contactStore.fetchContacts(client).catch((err) => console.error('Failed to fetch contacts:', err));
|
||||||
} else {
|
} else {
|
||||||
useContactStore.getState().setSupportsSync(false);
|
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)
|
// Success - save state (but NOT the password)
|
||||||
set({
|
set({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
@@ -138,6 +149,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// Clear contact store state
|
// Clear contact store state
|
||||||
useContactStore.getState().clearContacts();
|
useContactStore.getState().clearContacts();
|
||||||
|
|
||||||
|
// Clear vacation store state
|
||||||
|
useVacationStore.getState().clearState();
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
|
|||||||
+217
-2
@@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware';
|
|||||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
import type { JMAPClient } from '@/lib/jmap/client';
|
||||||
|
|
||||||
function getContactDisplayName(contact: ContactCard): string {
|
export function getContactDisplayName(contact: ContactCard): string {
|
||||||
if (contact.name?.components) {
|
if (contact.name?.components) {
|
||||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||||
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
||||||
@@ -35,6 +35,9 @@ interface ContactStore {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
supportsSync: boolean;
|
supportsSync: boolean;
|
||||||
|
|
||||||
|
selectedContactIds: Set<string>;
|
||||||
|
activeTab: 'all' | 'groups';
|
||||||
|
|
||||||
fetchContacts: (client: JMAPClient) => Promise<void>;
|
fetchContacts: (client: JMAPClient) => Promise<void>;
|
||||||
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
|
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
|
||||||
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
||||||
@@ -48,9 +51,27 @@ interface ContactStore {
|
|||||||
setSelectedContact: (id: string | null) => void;
|
setSelectedContact: (id: string | null) => void;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
setSupportsSync: (supports: boolean) => void;
|
setSupportsSync: (supports: boolean) => void;
|
||||||
|
setActiveTab: (tab: 'all' | 'groups') => void;
|
||||||
clearContacts: () => void;
|
clearContacts: () => void;
|
||||||
|
|
||||||
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
|
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>()(
|
export const useContactStore = create<ContactStore>()(
|
||||||
@@ -63,6 +84,8 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
supportsSync: false,
|
supportsSync: false,
|
||||||
|
selectedContactIds: new Set<string>(),
|
||||||
|
activeTab: 'all' as const,
|
||||||
|
|
||||||
fetchContacts: async (client) => {
|
fetchContacts: async (client) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
@@ -81,6 +104,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
set({ addressBooks });
|
set({ addressBooks });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch address books:', 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 }),
|
setSelectedContact: (id) => set({ selectedContactId: id }),
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
setSupportsSync: (supports) => set({ supportsSync: supports }),
|
setSupportsSync: (supports) => set({ supportsSync: supports }),
|
||||||
|
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||||
|
|
||||||
clearContacts: () => set({
|
clearContacts: () => set({
|
||||||
contacts: [],
|
contacts: [],
|
||||||
@@ -155,6 +180,8 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
selectedContactId: null,
|
selectedContactId: null,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
error: null,
|
error: null,
|
||||||
|
selectedContactIds: new Set<string>(),
|
||||||
|
activeTab: 'all',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getAutocomplete: (query) => {
|
getAutocomplete: (query) => {
|
||||||
@@ -165,6 +192,22 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
const results: Array<{ name: string; email: string }> = [];
|
const results: Array<{ name: string; email: string }> = [];
|
||||||
|
|
||||||
for (const contact of contacts) {
|
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 name = getContactDisplayName(contact);
|
||||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||||
|
|
||||||
@@ -183,6 +226,178 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
|
|
||||||
return results;
|
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',
|
name: 'contact-storage',
|
||||||
@@ -194,5 +409,5 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
export { getContactDisplayName, getContactPrimaryEmail };
|
export { getContactPrimaryEmail };
|
||||||
export type { ContactName };
|
export type { ContactName };
|
||||||
|
|||||||
+91
-8
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
||||||
import { JMAPClient } from "@/lib/jmap/client";
|
import { JMAPClient } from "@/lib/jmap/client";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||||
|
|
||||||
interface EmailStore {
|
interface EmailStore {
|
||||||
emails: Email[];
|
emails: Email[];
|
||||||
@@ -23,9 +24,14 @@ interface EmailStore {
|
|||||||
newEmailNotification: Email | null; // New email notification for toast
|
newEmailNotification: Email | null; // New email notification for toast
|
||||||
|
|
||||||
// Thread expansion state
|
// Thread expansion state
|
||||||
expandedThreadIds: Set<string>; // Which threads are expanded in the list
|
expandedThreadIds: Set<string>;
|
||||||
threadEmailsCache: Map<string, Email[]>; // Cache of fully fetched thread emails
|
threadEmailsCache: Map<string, Email[]>;
|
||||||
isLoadingThread: string | null; // Thread ID currently being loaded
|
isLoadingThread: string | null;
|
||||||
|
|
||||||
|
// Advanced search state
|
||||||
|
searchFilters: SearchFilters;
|
||||||
|
isAdvancedSearchOpen: boolean;
|
||||||
|
searchAbortController: AbortController | null;
|
||||||
|
|
||||||
setEmails: (emails: Email[]) => void;
|
setEmails: (emails: Email[]) => void;
|
||||||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||||||
@@ -51,6 +57,10 @@ interface EmailStore {
|
|||||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||||
searchEmails: (client: JMAPClient, query: 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>;
|
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
|
||||||
|
|
||||||
// Batch operations
|
// Batch operations
|
||||||
@@ -106,6 +116,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
threadEmailsCache: new Map(),
|
threadEmailsCache: new Map(),
|
||||||
isLoadingThread: null,
|
isLoadingThread: null,
|
||||||
|
|
||||||
|
// Advanced search state
|
||||||
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||||
|
isAdvancedSearchOpen: false,
|
||||||
|
searchAbortController: null,
|
||||||
|
|
||||||
// Spam undo cache
|
// Spam undo cache
|
||||||
spamUndoCache: new Map(),
|
spamUndoCache: new Map(),
|
||||||
|
|
||||||
@@ -222,15 +237,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
// Check if we're in search mode
|
const { searchFilters } = get();
|
||||||
if (searchQuery) {
|
const hasFilters = !isFilterEmpty(searchFilters);
|
||||||
// Load more search results (scoped to current mailbox)
|
|
||||||
|
if (searchQuery || hasFilters) {
|
||||||
const mailboxes = get().mailboxes;
|
const mailboxes = get().mailboxes;
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
// Only pass accountId for shared mailboxes
|
|
||||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
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 {
|
} else {
|
||||||
// Load more from mailbox
|
// Load more from mailbox
|
||||||
// Find the mailbox to get its accountId (for shared folder support)
|
// 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) => {
|
toggleStar: async (client, emailId) => {
|
||||||
try {
|
try {
|
||||||
const email = get().emails.find(e => e.id === emailId);
|
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