Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec5593f567 | ||
|
|
c30c38a7af | ||
|
|
7494fc1776 | ||
|
|
16fe92d2e9 | ||
|
|
3544e6a9e7 | ||
|
|
dd1f3e11e6 | ||
|
|
6503482b55 | ||
|
|
cab57f6cd7 | ||
|
|
27f4fbdce4 | ||
|
|
39a228b20e | ||
|
|
4b069808d6 | ||
|
|
468851ff25 | ||
|
|
b7374570c8 | ||
|
|
3c9fa5dc25 | ||
|
|
3ade1c6473 | ||
|
|
1810a474a2 | ||
|
|
40982bc37b | ||
|
|
76f6149841 | ||
|
|
7d6a3c8c76 | ||
|
|
361ad49f5f | ||
|
|
f9aa5cbaee | ||
|
|
3f36045990 | ||
|
|
9a44babcf1 | ||
|
|
f032758303 |
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.0 (2026-04-22)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
|
||||
|
||||
### Features
|
||||
|
||||
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
|
||||
- **Admin**: Add API Keys management and IP allowlist for App Passwords
|
||||
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
|
||||
- **Contacts**: Add contact activity component showing recent emails and upcoming events
|
||||
- **Contacts**: Add right-click context menu
|
||||
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
|
||||
- **Calendar**: Support resizing events from the top edge
|
||||
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
|
||||
- **Calendar**: Optimize layout of overlapping events
|
||||
- **Calendar**: Add collapsible details to calendar invitation banner
|
||||
- **Email**: Implement batch archiving and bulk moving of emails
|
||||
- **Email**: Show full folder path in move/drop toast
|
||||
- **Settings**: Reorganize settings into 6 groups with clearer tabs
|
||||
- **Navigation**: Add account-addition button to the navigation rail
|
||||
- **Mobile**: Streamline email viewer header layout
|
||||
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
|
||||
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
|
||||
- **Mailbox**: Improve error message on mailbox creation failure
|
||||
- **Auth**: Skip `checkAuth` on route change when already authenticated
|
||||
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
|
||||
- **UI**: Align hover styles and selection-toggle target with focused item
|
||||
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
|
||||
|
||||
### Refactor
|
||||
|
||||
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
|
||||
|
||||
### Chore
|
||||
|
||||
- **i18n**: Add missing translation keys
|
||||
- **Deps**: Bump dependencies to latest compatible versions
|
||||
|
||||
## 1.4.14 (2026-04-16)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Features
|
||||
|
||||
## Mail
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Draft auto-save with identity preservation
|
||||
- Attachment upload, download, and inline preview; forgotten-attachment warning
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||
- Star/unstar with configurable mark-as-read delay
|
||||
- Virtual scrolling for large mailboxes
|
||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||
- Plain-text composer mode and Reply-To support
|
||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||
- Folder management with icon picker, subfolders, and sidebar counts
|
||||
- Print directly from the viewer
|
||||
- Browser history sync for back/forward navigation
|
||||
|
||||
## Calendar
|
||||
|
||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||
- iCalendar import with preview, bulk create, and UID deduplication
|
||||
- iCal / webcal subscriptions with editing and batch import
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
|
||||
## Contacts
|
||||
|
||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||
- Multiple address books with drag-and-drop between books
|
||||
- Contact groups with member management
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Trusted senders stored in a dedicated JMAP address book
|
||||
- Autocomplete in the composer (To / Cc / Bcc)
|
||||
|
||||
## Filters & Templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
- Previews for images, text, audio, and video
|
||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||
- Newsletter unsubscribe (RFC 2369)
|
||||
|
||||
## Interface
|
||||
|
||||
- Three-pane layout with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
- Interactive guided tour for new users
|
||||
- Right-click context menus, toast notifications with undo
|
||||
- Customizable toolbar position, favicon, and login branding
|
||||
- Pinnable sidebar apps with drag-and-drop reordering
|
||||
- Encrypted settings sync across devices
|
||||
- Storage quota display
|
||||
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||
|
||||
## Internationalization
|
||||
|
||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
## Identity & Multi-Account
|
||||
|
||||
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
|
||||
- Account switcher with connection status and default account selection
|
||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
|
||||
- Shared folders across accounts
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
## Admin & Extensibility
|
||||
|
||||
- Stalwart admin dashboard with dedicated policy sections
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||
- Bundled plugins including Jitsi Meet calendar integration
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
||||
- Automatic update check with server-side logging of new releases
|
||||
- Structured logging (`text` or `json`) with category-based levels
|
||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||
- Demo mode with fixture data – no mail server required
|
||||
@@ -8,12 +8,11 @@
|
||||
|
||||
# Bulwark Webmail
|
||||
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
||||
Built with Next.js and the JMAP protocol.
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
|
||||
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
|
||||
</picture>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
|
||||
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||
|
||||
**Calendar** - Month, week, day, and agenda views with event management
|
||||
|
||||
</td>
|
||||
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
|
||||
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||
|
||||
**Contacts** - Contact management with groups and vCard support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||
|
||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||
|
||||
</td>
|
||||
<td><sub><b>Calendar</b> – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
|
||||
<td><sub><b>Contacts</b> – multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/theme.png" alt="Themes" /></td>
|
||||
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
|
||||
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
|
||||
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>More screenshots</summary>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
## Overview
|
||||
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
|
||||
**Light mode** - Full theme support with intelligent color transformation
|
||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a Stalwart admin dashboard.
|
||||
|
||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||
|
||||
**Settings** - Appearance, identities, filters, templates, and more
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||
|
||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
### Mail
|
||||
|
||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
||||
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
||||
- **Unified mailbox** - view emails across all accounts in a single list
|
||||
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
||||
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
||||
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
||||
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||
- **Print** emails directly from the viewer
|
||||
- **Answered/forwarded status icons** in email list and thread views
|
||||
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
||||
- **Star/unstar** with configurable mark-as-read delay
|
||||
- **Virtual scrolling** for large mailboxes
|
||||
- **Quick reply** from the viewer
|
||||
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
|
||||
- **Sender avatars** - favicon-based with negative caching for performance
|
||||
- **Recipient popover** for quick contact interaction
|
||||
- **Plain text composer mode** and auto-select reply identity
|
||||
- **Reply-to addresses** support in the composer
|
||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
- **message/rfc822 unwrapping** for embedded messages
|
||||
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** - unread and total counts displayed in sidebar
|
||||
- **Browser history sync** - back/forward navigation mirrors mail view state
|
||||
|
||||
### Calendar
|
||||
|
||||
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
|
||||
- **Event hover preview** popover with configurable details
|
||||
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
||||
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
||||
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
||||
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||
- **iCalendar import** with preview, bulk create, and UID deduplication
|
||||
- **iCal / webcal subscriptions** with editing and batch import
|
||||
- **Birthday calendar** - auto-generated from contacts
|
||||
- **Virtual locations** - video conference URLs as first-class event fields
|
||||
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
||||
- **Shared calendars** with visual grouping in the sidebar
|
||||
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
||||
- **Week numbers** in mini-calendar sidebar
|
||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||
- **Real-time sync** via JMAP push
|
||||
|
||||
### Contacts
|
||||
|
||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
||||
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
|
||||
- **Collapsible sidebar** with address book grouping and bulk operations
|
||||
- **Contact groups** with group expansion and member management
|
||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||
- **Trusted senders** stored in a dedicated JMAP address book
|
||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||
- **Bulk operations** - multi-select, delete, group add, export
|
||||
|
||||
### Filters & Automation
|
||||
|
||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
||||
- **External rule preservation** - rules authored in other clients are displayed and preserved
|
||||
- **Raw Sieve editor** with syntax validation
|
||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||
|
||||
### Files
|
||||
|
||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
||||
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
||||
- **Dynamic upload limits** - respects the server-configured maximum upload size
|
||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||
- **Grid and list views** with sorting by name, size, or date
|
||||
- **Clipboard operations** - cut, copy, paste, duplicate files
|
||||
- **File preview** for images, text, audio, video, and more
|
||||
- **Favorites and recent files** for quick access
|
||||
- **Bulk operations** - multi-select, delete, move, download
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
- **External content blocked** by default - trusted senders list for auto-load
|
||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
||||
- **SPF/DKIM/DMARC** status indicators
|
||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
||||
- **TOTP two-factor authentication**
|
||||
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
||||
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
||||
- **Newsletter unsubscribe** (RFC 2369)
|
||||
|
||||
### Interface
|
||||
|
||||
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
||||
- **Dark and light themes** with intelligent email color transformation
|
||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
||||
- **Keyboard shortcuts** - full navigation without a mouse
|
||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||
- **Interactive guided tour** - onboarding walkthrough for new users
|
||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
||||
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||
- **Storage quota** display
|
||||
- **Version badge** in settings
|
||||
- **Focused mode** with proper viewport bounds
|
||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
|
||||
### Internationalization
|
||||
|
||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
### Identity Management
|
||||
|
||||
- **Multiple sender identities** with per-identity signatures
|
||||
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
||||
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||
- **Identity badges** in viewer and email list
|
||||
|
||||
### Multi-Account
|
||||
|
||||
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
||||
- **Account switcher** with connection status, default account selection, and per-account logout
|
||||
- **Per-account settings** - encrypted settings storage with server-side sync
|
||||
- **Shared folders** across accounts
|
||||
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
### Admin & Extensibility
|
||||
|
||||
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
|
||||
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
||||
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
|
||||
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||
- **Bundled plugins** - Jitsi Meet calendar integration
|
||||
|
||||
### Operations
|
||||
|
||||
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
||||
- **Automatic update check** - server logs when a newer release is available
|
||||
- **Logging categories** with `text` or `json` formats for log aggregation
|
||||
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
|
||||
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
@@ -255,7 +82,7 @@ Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
|
||||
cd webmail
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
npm run build && npm start
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (mock JMAP server included)
|
||||
npm run typecheck # Type checking
|
||||
npm run lint # Linting
|
||||
npm run dev # Dev server with a mock JMAP server
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `.env.local`:
|
||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||
|
||||
```env
|
||||
# Required
|
||||
@@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||
|
||||
<details>
|
||||
<summary>Server Listen Address</summary>
|
||||
<summary>Server listen address</summary>
|
||||
|
||||
```env
|
||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||
PORT=3000 # Default listen port
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OAuth2/OIDC (SSO)</summary>
|
||||
<summary>OAuth2 / OIDC</summary>
|
||||
|
||||
```env
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||
```
|
||||
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||
@@ -318,29 +143,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Remember Me & Settings Sync</summary>
|
||||
<summary>Session & settings sync</summary>
|
||||
|
||||
```env
|
||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
||||
SESSION_SECRET= # openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
|
||||
|
||||
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
|
||||
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
|
||||
SETTINGS_SYNC_ENABLED=true
|
||||
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
|
||||
```
|
||||
|
||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
||||
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom JMAP Endpoint</summary>
|
||||
<summary>Custom JMAP endpoint</summary>
|
||||
|
||||
```env
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||
```
|
||||
|
||||
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
|
||||
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
|
||||
|
||||
```env
|
||||
APP_NAME=My Webmail
|
||||
APP_SHORT_NAME=Webmail # Home-screen label on mobile
|
||||
APP_DESCRIPTION=Your personal mail # Shown during PWA install
|
||||
APP_SHORT_NAME=Webmail
|
||||
APP_DESCRIPTION=Your personal mail
|
||||
|
||||
FAVICON_URL=/branding/favicon.svg
|
||||
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
|
||||
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
|
||||
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6
|
||||
PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||
@@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extension Directory</summary>
|
||||
<summary>Extension directory</summary>
|
||||
|
||||
```env
|
||||
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
@@ -382,13 +206,13 @@ Enables the admin marketplace for browsing and installing plugins and themes.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Stalwart Integration & Logging</summary>
|
||||
<summary>Stalwart integration & logging</summary>
|
||||
|
||||
```env
|
||||
STALWART_FEATURES=true # Password change, sieve filters, etc.
|
||||
STALWART_FEATURES=true # password change, Sieve filters, etc.
|
||||
|
||||
LOG_FORMAT=text # "text" or "json"
|
||||
LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||
LOG_LEVEL=info # error | warn | info | debug
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -423,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||
|
||||
## Why Stalwart?
|
||||
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL v3](LICENSE)
|
||||
|
||||
This repository also preserves the original MIT attribution notice for the
|
||||
fork lineage in [NOTICE](NOTICE).
|
||||
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
|
||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
|
||||
|
||||
@@ -126,8 +126,15 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [events, detailEvent]);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
|
||||
@@ -98,13 +98,20 @@ export default function ContactsPage() {
|
||||
|
||||
// Panel resize state - contact list
|
||||
const [listWidth, setListWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
|
||||
});
|
||||
const [isListResizing, setIsListResizing] = useState(false);
|
||||
const listDragStartWidth = useRef(320);
|
||||
const listDragStartWidth = useRef(384);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
@@ -172,21 +179,6 @@ export default function ContactsPage() {
|
||||
return getGroupMembers(activeCategory.groupId);
|
||||
}, [activeCategory, individuals, getGroupMembers]);
|
||||
|
||||
// Label for the current category
|
||||
const categoryLabel = useMemo(() => {
|
||||
if (activeCategory === "all") return t("tabs.all");
|
||||
if (activeCategory === "uncategorized") return t("no_category");
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||
return book?.name || t("tabs.all");
|
||||
}
|
||||
if ("keyword" in activeCategory) {
|
||||
return activeCategory.keyword;
|
||||
}
|
||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||
}, [activeCategory, contacts, addressBooks, t]);
|
||||
|
||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||
setActiveCategory(category);
|
||||
clearSelection();
|
||||
@@ -306,6 +298,24 @@ export default function ContactsPage() {
|
||||
setView("bulk-add-to-group");
|
||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||
|
||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||
const { id: _id, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _created; void _updated;
|
||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
@@ -607,6 +617,16 @@ export default function ContactsPage() {
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddToGroup={
|
||||
selectedContact
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={
|
||||
selectedContact
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
@@ -702,7 +722,6 @@ export default function ContactsPage() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
categoryLabel={categoryLabel}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
@@ -726,7 +745,7 @@ export default function ContactsPage() {
|
||||
setIsListResizing(false);
|
||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
|
||||
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -105,8 +105,15 @@ export default function FilesPage() {
|
||||
|
||||
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
@@ -455,8 +455,15 @@ export default function Home() {
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
@@ -493,7 +500,10 @@ export default function Home() {
|
||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||
const loadData = async () => {
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
||||
await Promise.all([
|
||||
@@ -505,6 +515,15 @@ export default function Home() {
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
// On first login the server may still be provisioning mailboxes.
|
||||
// Retry a few times with back-off before giving up.
|
||||
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||
const delay = Math.min(1000 * attempt, 5000);
|
||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch emails for the selected mailbox
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
@@ -538,6 +557,12 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
client.closePushNotifications();
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup push notifications on unmount
|
||||
@@ -813,8 +838,11 @@ export default function Home() {
|
||||
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToArchive) return;
|
||||
|
||||
// Find archive mailbox
|
||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
||||
// and each iteration needs to see folders created by prior iterations.
|
||||
const currentMailboxes = useEmailStore.getState().mailboxes;
|
||||
|
||||
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
if (!archiveMailbox) return;
|
||||
|
||||
const { archiveMode } = useSettingsStore.getState();
|
||||
@@ -823,14 +851,12 @@ export default function Home() {
|
||||
if (archiveMode === 'single') {
|
||||
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||
} else {
|
||||
// Determine year/month from the email's received date
|
||||
const emailDate = new Date(emailToArchive.receivedAt);
|
||||
const year = emailDate.getFullYear().toString();
|
||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||
|
||||
// Find or create year subfolder under archive
|
||||
let yearMailbox = mailboxes.find(
|
||||
let yearMailbox = currentMailboxes.find(
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
if (!yearMailbox) {
|
||||
@@ -841,9 +867,9 @@ export default function Home() {
|
||||
if (archiveMode === 'year') {
|
||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||
} else {
|
||||
// archiveMode === 'month' - find or create month subfolder under year
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = mailboxes.find(
|
||||
const afterYear = useEmailStore.getState().mailboxes;
|
||||
let monthMailbox = afterYear.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
);
|
||||
if (!monthMailbox) {
|
||||
@@ -1492,9 +1518,12 @@ export default function Home() {
|
||||
} else {
|
||||
selectAllEmails();
|
||||
}
|
||||
} else {
|
||||
// Enter selection mode by selecting the first email
|
||||
if (emails.length > 0) toggleEmailSelection(emails[0].id);
|
||||
} else if (emails.length > 0) {
|
||||
const currentId = selectedEmail?.id;
|
||||
const target = currentId && emails.some((e) => e.id === currentId)
|
||||
? currentId
|
||||
: emails[0].id;
|
||||
toggleEmailSelection(target);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -1898,14 +1927,6 @@ export default function Home() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile Header for Viewer */}
|
||||
{isMobile && activeView === "viewer" && (
|
||||
<MobileViewerHeader
|
||||
subject={selectedEmail?.subject}
|
||||
onBack={handleMobileBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
LogOut,
|
||||
Settings as SettingsIcon,
|
||||
Palette,
|
||||
Mail,
|
||||
User,
|
||||
Shield,
|
||||
UserPen,
|
||||
@@ -20,17 +19,27 @@ import {
|
||||
FolderOpen,
|
||||
Tags,
|
||||
HardDrive,
|
||||
Wrench,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
LayoutGrid,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
EyeOff,
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { EmailSettings } from '@/components/settings/email-settings';
|
||||
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
|
||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { AboutDataSettings } from '@/components/settings/about-data-settings';
|
||||
import { DebugSettings } from '@/components/settings/debug-settings';
|
||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
type Tab =
|
||||
| 'account'
|
||||
| 'language'
|
||||
| 'notifications'
|
||||
| 'appearance'
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
| 'templates'
|
||||
| 'folders'
|
||||
| 'keywords'
|
||||
| 'security'
|
||||
| 'encryption'
|
||||
| 'content_senders'
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
| 'files'
|
||||
| 'sidebar_apps'
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
@@ -74,28 +109,54 @@ interface TabDef {
|
||||
}
|
||||
|
||||
const tabIcons: Record<Tab, LucideIcon> = {
|
||||
appearance: Palette,
|
||||
email: Mail,
|
||||
notifications: Bell,
|
||||
account: User,
|
||||
security: Shield,
|
||||
language: Languages,
|
||||
notifications: Bell,
|
||||
appearance: Palette,
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
identities: UserPen,
|
||||
encryption: KeyRound,
|
||||
vacation: PalmtreeIcon,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
security: Shield,
|
||||
encryption: KeyRound,
|
||||
content_senders: EyeOff,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
plugins: Puzzle,
|
||||
advanced: Wrench,
|
||||
debug: Bug,
|
||||
};
|
||||
|
||||
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
||||
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||
|
||||
// Map legacy tab IDs to current ones; runs once on read of localStorage.
|
||||
const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
email: 'reading',
|
||||
advanced: 'about_data',
|
||||
};
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
try {
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (!saved) return 'appearance';
|
||||
if (saved in LEGACY_TAB_MAP) {
|
||||
const migrated = LEGACY_TAB_MAP[saved];
|
||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||
return migrated;
|
||||
}
|
||||
return saved as Tab;
|
||||
} catch {
|
||||
return 'appearance';
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
@@ -107,13 +168,7 @@ export default function SettingsPage() {
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (saved) return saved as Tab;
|
||||
} catch { /* ignore */ }
|
||||
return 'appearance';
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
@@ -124,21 +179,28 @@ export default function SettingsPage() {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(256);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Listen for tab change events from child components
|
||||
// Listen for tab change events from child components (with legacy migration)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const tab = (e as CustomEvent).detail as Tab;
|
||||
if (tab) {
|
||||
setActiveTab(tab);
|
||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||
}
|
||||
const raw = (e as CustomEvent).detail as string;
|
||||
if (!raw) return;
|
||||
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
|
||||
setActiveTab(tab);
|
||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||
};
|
||||
window.addEventListener('settings-tab-change', handler);
|
||||
return () => window.removeEventListener('settings-tab-change', handler);
|
||||
@@ -161,25 +223,41 @@ export default function SettingsPage() {
|
||||
const supportsFiles = client?.supportsFiles() ?? false;
|
||||
|
||||
const tabs: TabDef[] = [
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
||||
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
||||
// General
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||
|
||||
// Privacy & Security
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
|
||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
|
||||
// Group tabs by category
|
||||
@@ -191,6 +269,10 @@ export default function SettingsPage() {
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||
|
||||
const handleTabSelect = (tabId: Tab) => {
|
||||
setActiveTab(tabId);
|
||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||
@@ -199,39 +281,42 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
|
||||
const renderTabContent = () => (
|
||||
<>
|
||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||
{activeTab === 'email' && <EmailSettings />}
|
||||
{activeTab === 'notifications' && <NotificationSettings />}
|
||||
{activeTab === 'account' && <AccountSettings />}
|
||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
||||
{activeTab === 'identities' && <IdentitySettings />}
|
||||
{activeTab === 'encryption' && <SmimeSettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
{activeTab === 'keywords' && <KeywordSettings />}
|
||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{activeTab === 'themes' && <ThemesSettings />}
|
||||
{activeTab === 'plugins' && <PluginsSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (!isDesktop) {
|
||||
// Mobile: show content view
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
{/* Mobile content header */}
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -244,14 +329,12 @@ export default function SettingsPage() {
|
||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -264,10 +347,8 @@ export default function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: show tab list
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -283,7 +364,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="py-2">
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
@@ -319,7 +399,6 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={logout}
|
||||
@@ -331,7 +410,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -347,7 +425,6 @@ export default function SettingsPage() {
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="flex h-dvh bg-background">
|
||||
{/* Navigation Rail */}
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -366,7 +443,6 @@ export default function SettingsPage() {
|
||||
)}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
{/* Settings Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary flex flex-col",
|
||||
@@ -374,7 +450,6 @@ export default function SettingsPage() {
|
||||
)}
|
||||
style={{ width: `${settingsSidebarWidth}px` }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -387,7 +462,6 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
@@ -406,14 +480,14 @@ export default function SettingsPage() {
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
activeTab === tab.id
|
||||
effectiveActiveTab === tab.id
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{tab.label}
|
||||
{tab.experimental && (
|
||||
@@ -430,7 +504,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar resize handle */}
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
@@ -441,10 +514,8 @@ export default function SettingsPage() {
|
||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||
/>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||
@@ -452,7 +523,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Tab Content */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
@@ -112,10 +112,9 @@ const emails: MockEmail[] = [
|
||||
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }],
|
||||
htmlBody: [],
|
||||
bodyValues: {
|
||||
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
|
||||
p2: { value: '<p>Hallo!</p><p>This is a sample email to help you get started with the <strong>Bulwark Webmail</strong> development environment.</p><p>Feel free to explore the UI - all data here is mock data.</p><p>Beste Grüße,<br>Sophie</p>' },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -119,6 +119,7 @@ export function CalendarDayView({
|
||||
created: t("notifications.event_created"),
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
isMobile,
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
@@ -246,9 +247,11 @@ export function CalendarDayView({
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const isResizing = resizeVisual?.eventId === ev.id;
|
||||
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
@@ -270,11 +273,21 @@ export function CalendarDayView({
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
|
||||
@@ -183,6 +183,7 @@ export function CalendarWeekView({
|
||||
created: t("notifications.event_created"),
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
isMobile,
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
@@ -384,9 +385,11 @@ export function CalendarWeekView({
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const isResizing = resizeVisual?.eventId === ev.id;
|
||||
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
@@ -408,11 +411,21 @@ export function CalendarWeekView({
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
|
||||
@@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
}, 200);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}, [query, addParticipant]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
key={`${p.email}-${i}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
||||
>
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRemove(p.email);
|
||||
setQuery(p.email);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
|
||||
aria-label={`${t("edit")} ${p.name || p.email}`}
|
||||
>
|
||||
{p.name || p.email}
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
||||
const deleteButton = screen.getAllByRole('button').find(
|
||||
btn => btn.className.includes('text-red')
|
||||
);
|
||||
fireEvent.click(deleteButton!);
|
||||
fireEvent.click(screen.getByLabelText('detail.more_actions'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,6 @@ const defaultProps = {
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
categoryLabel: 'All Contacts',
|
||||
selectedContactIds: new Set<string>(),
|
||||
onToggleSelection: vi.fn(),
|
||||
onSelectRangeContacts: vi.fn(),
|
||||
@@ -79,8 +78,4 @@ describe('ContactList', () => {
|
||||
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows category label with count', () => {
|
||||
render(<ContactList {...defaultProps} />);
|
||||
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Mail, CalendarDays, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Section } from "./contact-detail";
|
||||
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
@@ -43,14 +44,15 @@ function buildEmailFilter(addresses: string[]): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
|
||||
if (!event.participants) return false;
|
||||
for (const p of Object.values(event.participants)) {
|
||||
const email = p.email?.trim().toLowerCase();
|
||||
if (email && addresses.has(email)) return true;
|
||||
if (p.sendTo) {
|
||||
for (const target of Object.values(p.sendTo)) {
|
||||
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
const email = p.email?.trim().toLowerCase();
|
||||
if (email && addresses.has(email)) return true;
|
||||
if (p.sendTo) {
|
||||
for (const target of Object.values(p.sendTo)) {
|
||||
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +64,26 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
function getEmailSender(email: Email): { name: string; address: string } {
|
||||
const from = email.from?.[0];
|
||||
return {
|
||||
name: from?.name?.trim() || from?.email || "",
|
||||
address: from?.email || "",
|
||||
};
|
||||
}
|
||||
|
||||
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||
const groups = new Map<string, CalendarEvent[]>();
|
||||
for (const e of events) {
|
||||
const d = new Date(e.start);
|
||||
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
|
||||
const arr = groups.get(key) || [];
|
||||
arr.push(e);
|
||||
groups.set(key, arr);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
const t = useTranslations("contacts.activity");
|
||||
const format = useFormatter();
|
||||
@@ -170,111 +192,115 @@ export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
: { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatEventDate = (event: CalendarEvent) => {
|
||||
const formatEventTime = (event: CalendarEvent) => {
|
||||
if (event.showWithoutTime) return t("all_day");
|
||||
const d = new Date(event.start);
|
||||
if (isNaN(d.getTime())) return event.start;
|
||||
if (event.showWithoutTime) {
|
||||
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
return format.dateTime(d, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const formatEventDateHeader = (isoDate: string) => {
|
||||
const d = new Date(isoDate);
|
||||
if (isNaN(d.getTime())) return isoDate;
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
return format.dateTime(d, sameYear
|
||||
? { weekday: "long", month: "long", day: "numeric" }
|
||||
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||
};
|
||||
|
||||
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivitySection icon={Mail} title={t("recent_emails")}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
|
||||
<Section title={t("recent_emails")}>
|
||||
{emailsLoading ? (
|
||||
<LoadingRow />
|
||||
) : emailsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
|
||||
) : (
|
||||
emails.map((email) => (
|
||||
<button
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEmail(email)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{email.subject || t("no_subject")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEmailDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{email.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{email.preview}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
<div className="-mx-2">
|
||||
{emails.map((email) => {
|
||||
const sender = getEmailSender(email);
|
||||
return (
|
||||
<button
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEmail(email)}
|
||||
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<Avatar name={sender.name} email={sender.address} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{sender.name || t("unknown_sender")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEmailDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm truncate">
|
||||
{email.subject || t("no_subject")}
|
||||
</div>
|
||||
{email.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{email.preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ActivitySection>
|
||||
</Section>
|
||||
|
||||
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
|
||||
<Section title={t("upcoming_events")}>
|
||||
{eventsLoading ? (
|
||||
<LoadingRow />
|
||||
) : eventsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !events || events.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEvent(event)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{event.title || t("no_title")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEventDate(event)}
|
||||
</span>
|
||||
<div className="space-y-4">
|
||||
{eventGroups.map(([dateKey, group]) => (
|
||||
<div key={dateKey}>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{formatEventDateHeader(dateKey)}
|
||||
</div>
|
||||
<div className="-mx-2">
|
||||
{group.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEvent(event)}
|
||||
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
|
||||
{formatEventTime(event)}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1 min-w-0">
|
||||
{event.title || t("no_title")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ActivitySection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivitySection({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1 pl-6">{children}</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,16 +11,25 @@ import {
|
||||
Eye,
|
||||
Pencil,
|
||||
Mail,
|
||||
Phone,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
Users,
|
||||
Trash2,
|
||||
Copy,
|
||||
Printer,
|
||||
} from "lucide-react";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
function getContactPrimaryPhone(contact: ContactCard): string {
|
||||
if (!contact.phones) return "";
|
||||
return Object.values(contact.phones)[0]?.number || "";
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -38,6 +47,7 @@ interface ContactContextMenuProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onBatchExport?: () => void;
|
||||
onBatchAddToGroup?: () => void;
|
||||
onBatchDelete?: () => void;
|
||||
@@ -55,12 +65,14 @@ export function ContactContextMenu({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddToGroup,
|
||||
onDuplicate,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
}: ContactContextMenuProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const phone = getContactPrimaryPhone(contact);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
|
||||
const handle = (fn: () => void) => () => {
|
||||
@@ -73,10 +85,15 @@ export function ContactContextMenu({
|
||||
window.location.href = `mailto:${email}`;
|
||||
};
|
||||
|
||||
const handleCopyEmail = async () => {
|
||||
if (!email) return;
|
||||
const handleCall = () => {
|
||||
if (!phone) return;
|
||||
window.location.href = `tel:${phone}`;
|
||||
};
|
||||
|
||||
const handleCopy = async (value: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(t("detail.copied"));
|
||||
} catch {
|
||||
toast.error(t("detail.copy_failed"));
|
||||
@@ -88,6 +105,10 @@ export function ContactContextMenu({
|
||||
toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printContact(contact);
|
||||
};
|
||||
|
||||
if (showBatchActions) {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
@@ -122,20 +143,34 @@ export function ContactContextMenu({
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||
{(email || phone) && <ContextMenuSeparator />}
|
||||
{email && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(handleSendEmail)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_email")}
|
||||
onClick={handle(handleCopyEmail)}
|
||||
/>
|
||||
</>
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(handleSendEmail)}
|
||||
/>
|
||||
)}
|
||||
{phone && (
|
||||
<ContextMenuItem
|
||||
icon={Phone}
|
||||
label={t("context_menu.call")}
|
||||
onClick={handle(handleCall)}
|
||||
/>
|
||||
)}
|
||||
{email && (
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_email")}
|
||||
onClick={handle(() => handleCopy(email))}
|
||||
/>
|
||||
)}
|
||||
{phone && (
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_phone")}
|
||||
onClick={handle(() => handleCopy(phone))}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
@@ -143,11 +178,23 @@ export function ContactContextMenu({
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
{onDuplicate && (
|
||||
<ContextMenuItem
|
||||
icon={Copy}
|
||||
label={t("context_menu.duplicate")}
|
||||
onClick={handle(onDuplicate)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
onClick={handle(handleExport)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Printer}
|
||||
label={t("context_menu.print")}
|
||||
onClick={handle(handlePrint)}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
|
||||
type MoreItem =
|
||||
| {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
destructive?: boolean;
|
||||
separator?: false;
|
||||
}
|
||||
| { separator: true };
|
||||
|
||||
interface ContactDetailProps {
|
||||
contact: ContactCard | null;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
isMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -27,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
|
||||
return Object.keys(features).filter(k => features[k]).join(", ");
|
||||
}
|
||||
|
||||
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||
if (typeof dateInput === "object" && dateInput !== null) {
|
||||
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
|
||||
const d = new Date(dateInput.utc);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
return { year: pd.year, month: pd.month, day: pd.day };
|
||||
}
|
||||
const s = String(dateInput);
|
||||
if (s.startsWith("--")) {
|
||||
const parts = s.substring(2).split("-");
|
||||
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||
}
|
||||
const d = new Date(s);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
const { year, month, day } = getDateParts(dateInput);
|
||||
if (!year) return null;
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - year;
|
||||
const m = month ?? 1;
|
||||
const d = day ?? 1;
|
||||
const nowM = now.getMonth() + 1;
|
||||
const nowD = now.getDate();
|
||||
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
|
||||
if (years < 0) return null;
|
||||
return years;
|
||||
}
|
||||
|
||||
function formatDate(dateInput: AnniversaryDate): string {
|
||||
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
||||
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||
try {
|
||||
@@ -41,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
return String(dateInput.utc);
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
const year = pd.year;
|
||||
const month = pd.month;
|
||||
const day = pd.day;
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const parts: string[] = [];
|
||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
||||
if (day) parts.push(String(day));
|
||||
if (year) parts.push(String(year));
|
||||
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
|
||||
if (pd.day) parts.push(String(pd.day));
|
||||
if (pd.year) parts.push(String(pd.year));
|
||||
return parts.join(' ') || String(dateInput);
|
||||
}
|
||||
const dateStr = String(dateInput);
|
||||
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
|
||||
if (dateStr.startsWith("--")) {
|
||||
// Partial date without year
|
||||
const parts = dateStr.substring(2).split("-");
|
||||
const month = parseInt(parts[0], 10);
|
||||
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
|
||||
@@ -70,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
|
||||
@@ -88,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string | null = null;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
// data URI - extract base64 content
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
@@ -97,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
// PEM-encoded certificate inline
|
||||
derBytes = key.uri;
|
||||
}
|
||||
if (!derBytes) continue;
|
||||
@@ -128,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const photoUri = getContactPhotoUri(contact);
|
||||
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
|
||||
|
||||
const handleExport = () => {
|
||||
exportContact(contact);
|
||||
toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printContact(contact, name);
|
||||
};
|
||||
|
||||
const moreItems: MoreItem[] = [];
|
||||
if (onAddToGroup) {
|
||||
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
|
||||
}
|
||||
if (onDuplicate) {
|
||||
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
|
||||
}
|
||||
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
|
||||
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
|
||||
moreItems.push({ separator: true });
|
||||
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
@@ -171,257 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
const hasNickname = nicknames.length > 0;
|
||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
||||
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
|
||||
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
{hasNickname && (
|
||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||
)}
|
||||
{titleLine && (
|
||||
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
|
||||
)}
|
||||
{orgs.length > 0 && orgs[0].name && (
|
||||
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
|
||||
{subtitleParts.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||
{email && (
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
{t("detail.compose_email")}
|
||||
</a>
|
||||
)}
|
||||
{phone && (
|
||||
<a
|
||||
href={`tel:${phone}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-1" />
|
||||
{t("context_menu.call")}
|
||||
</a>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||
<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 touch-manipulation">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{/* Contact info */}
|
||||
{emails.length > 0 && (
|
||||
<Section icon={Mail} title={t("detail.emails")} category="contact">
|
||||
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
|
||||
{hasContactDetails && (
|
||||
<Section title={t("detail.section_contact")}>
|
||||
<div className="space-y-3">
|
||||
{emails.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
|
||||
{e.address}
|
||||
</a>
|
||||
{e.contexts && <ContextBadge contexts={e.contexts} />}
|
||||
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
|
||||
<div className={cn(
|
||||
"flex items-center gap-0.5 transition-opacity",
|
||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}>
|
||||
<a
|
||||
href={`mailto:${e.address}`}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
|
||||
title={t("detail.compose_email")}
|
||||
aria-label={t("detail.compose_email")}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
|
||||
<div className="flex items-center gap-2 group">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
|
||||
{e.address}
|
||||
</a>
|
||||
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
<RowActions>
|
||||
<a
|
||||
href={`mailto:${e.address}`}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
|
||||
title={t("detail.compose_email")}
|
||||
aria-label={t("detail.compose_email")}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</a>
|
||||
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
</RowActions>
|
||||
</div>
|
||||
</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{phones.length > 0 && (
|
||||
<Section icon={Phone} title={t("detail.phones")} category="contact">
|
||||
{phones.map((p, i) => {
|
||||
const featureStr = formatPhoneFeatures(p.features);
|
||||
const features = formatPhoneFeatures(p.features);
|
||||
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||
{p.number}
|
||||
</a>
|
||||
{p.contexts && <ContextBadge contexts={p.contexts} />}
|
||||
{featureStr && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
|
||||
)}
|
||||
<CopyButton
|
||||
value={p.number}
|
||||
label={t("detail.copy_phone")}
|
||||
successMsg={t("detail.copied")}
|
||||
failMsg={t("detail.copy_failed")}
|
||||
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
||||
/>
|
||||
</div>
|
||||
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
|
||||
<div className="flex items-center gap-2 group">
|
||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||
{p.number}
|
||||
</a>
|
||||
<RowActions>
|
||||
<CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
</RowActions>
|
||||
</div>
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(roles.length > 0 || jobTitles.length > 1) && (
|
||||
<Section icon={Briefcase} title={t("detail.titles")} category="work">
|
||||
{titles.map((tl, i) => (
|
||||
<div key={i} className="text-sm flex items-center gap-2">
|
||||
<span>{tl.name}</span>
|
||||
{tl.kind && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{orgs.length > 0 && (
|
||||
<Section icon={Building} title={t("detail.organizations")} category="work">
|
||||
{orgs.map((o, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Addresses span full width */}
|
||||
{addresses.length > 0 && (
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<Section icon={MapPin} title={t("detail.addresses")} category="location">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{addresses.map((a, i) => (
|
||||
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
||||
<div>
|
||||
{a.full || a.fullAddress
|
||||
? (a.full || a.fullAddress)
|
||||
: a.components && a.components.length > 0
|
||||
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
||||
</div>
|
||||
{addresses.map((a, i) => {
|
||||
const lines: string[] = [];
|
||||
if (a.full || a.fullAddress) {
|
||||
lines.push((a.full || a.fullAddress) as string);
|
||||
} else if (a.components && a.components.length > 0) {
|
||||
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
|
||||
if (joined) lines.push(joined);
|
||||
} else {
|
||||
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
|
||||
.map(s => (typeof s === "string" ? s.trim() : ""))
|
||||
.filter(Boolean) as string[];
|
||||
lines.push(...parts);
|
||||
}
|
||||
return (
|
||||
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
|
||||
<div className="text-sm space-y-0.5">
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx}>{line}</div>
|
||||
))}
|
||||
{a.timeZone && (
|
||||
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{onlineServices.length > 0 && (
|
||||
<Section icon={Globe} title={t("detail.online_services")} category="digital">
|
||||
{onlineServices.map((svc, i) => (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
|
||||
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{svc.user || svc.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
||||
)}
|
||||
{svc.service && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
|
||||
)}
|
||||
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
|
||||
<CopyButton
|
||||
value={svc.user || svc.uri}
|
||||
label={t("detail.copy_url")}
|
||||
successMsg={t("detail.copied")}
|
||||
failMsg={t("detail.copy_failed")}
|
||||
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
||||
/>
|
||||
</div>
|
||||
<FieldRow
|
||||
key={`os${i}`}
|
||||
icon={Globe}
|
||||
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
|
||||
>
|
||||
<div className="flex items-center gap-2 group">
|
||||
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
|
||||
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{svc.user || svc.uri}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
|
||||
)}
|
||||
<RowActions>
|
||||
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
</RowActions>
|
||||
</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{anniversaries.length > 0 && (
|
||||
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
|
||||
{anniversaries.map((ann, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{formatDate(ann.date)}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{t(`detail.anniversary_${ann.kind}`)}
|
||||
</span>
|
||||
</div>
|
||||
{hasWork && (
|
||||
<Section title={t("detail.section_work")}>
|
||||
<div className="space-y-3">
|
||||
{orgs.map((o, i) => (
|
||||
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
|
||||
<div className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{personalInfo.length > 0 && (
|
||||
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
|
||||
{personalInfo.map((pi, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{pi.value}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
|
||||
{pi.level && (
|
||||
<span className="text-xs text-muted-foreground">({pi.level})</span>
|
||||
)}
|
||||
</div>
|
||||
{titles.map((tl, i) => (
|
||||
<FieldRow
|
||||
key={`tl${i}`}
|
||||
icon={Briefcase}
|
||||
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
|
||||
>
|
||||
<div className="text-sm">{tl.name}</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
|
||||
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
||||
<div className="text-sm">
|
||||
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
|
||||
{contact.speakToAs.pronouns && (() => {
|
||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||
return firstPronoun ? (
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{preferredLanguages.length > 0 && (
|
||||
<Section icon={Languages} title={t("detail.languages")} category="personal">
|
||||
{hasPersonal && (
|
||||
<Section title={t("detail.section_personal")}>
|
||||
<div className="space-y-3">
|
||||
{anniversaries.map((ann, i) => {
|
||||
const years = getCompletedYears(ann.date);
|
||||
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
|
||||
return (
|
||||
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
|
||||
<div className="text-sm">
|
||||
{formatDate(ann.date)}
|
||||
{years !== null && (
|
||||
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
|
||||
)}
|
||||
</div>
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
{hasGender && (
|
||||
<FieldRow icon={UserCircle} label={t("detail.gender")}>
|
||||
<div className="text-sm">
|
||||
{contact.speakToAs?.grammaticalGender && (
|
||||
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
|
||||
)}
|
||||
{contact.speakToAs?.pronouns && (() => {
|
||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||
return firstPronoun ? (
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</FieldRow>
|
||||
)}
|
||||
{preferredLanguages.map((lang, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{lang.language}</span>
|
||||
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
|
||||
</div>
|
||||
<FieldRow
|
||||
key={`lg${i}`}
|
||||
icon={Languages}
|
||||
label={formatContexts(lang.contexts) || t("detail.language_label")}
|
||||
>
|
||||
<div className="text-sm">{lang.language}</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{personalInfo.map((pi, i) => (
|
||||
<FieldRow
|
||||
key={`pi${i}`}
|
||||
icon={Heart}
|
||||
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
|
||||
>
|
||||
<div className="text-sm">{pi.value}</div>
|
||||
</FieldRow>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{keywords.length > 0 && (
|
||||
<Section icon={Tag} title={t("detail.categories")} category="digital">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{keywords.length > 0 && (
|
||||
<Section title={t("detail.categories")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{relatedTo.length > 0 && (
|
||||
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
|
||||
{relatedTo.length > 0 && (
|
||||
<Section title={t("detail.related_contacts")}>
|
||||
<div className="space-y-2">
|
||||
{relatedTo.map(([uri, rel], i) => {
|
||||
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span>{uri}</span>
|
||||
{relType && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
|
||||
)}
|
||||
</div>
|
||||
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
|
||||
<div className="text-sm break-all">{uri}</div>
|
||||
</FieldRow>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section title={t("detail.crypto_keys")}>
|
||||
<div className="space-y-3">
|
||||
{cryptoKeys.map((key, i) => {
|
||||
const certInfo = parsedCerts.get(i);
|
||||
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
|
||||
@@ -430,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
{certInfo ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -451,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
|
||||
</div>
|
||||
{!alreadyImported && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-4 mt-1"
|
||||
onClick={() => handleImportContactCert(i)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t("detail.import_to_smime")}
|
||||
</Button>
|
||||
@@ -466,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm break-all">
|
||||
<div className="flex items-start gap-2 text-sm break-all">
|
||||
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
|
||||
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{key.uri}
|
||||
@@ -479,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
||||
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
|
||||
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
||||
<Section title={t("detail.calendar")}>
|
||||
<div className="space-y-3">
|
||||
{contact.calendarUri && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
|
||||
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
|
||||
</div>
|
||||
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
|
||||
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{contact.calendarUri}
|
||||
</a>
|
||||
</FieldRow>
|
||||
)}
|
||||
{contact.schedulingUri && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
|
||||
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
|
||||
</div>
|
||||
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
|
||||
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{contact.schedulingUri}
|
||||
</a>
|
||||
</FieldRow>
|
||||
)}
|
||||
{contact.freeBusyUri && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
|
||||
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
|
||||
</div>
|
||||
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
|
||||
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
|
||||
{contact.freeBusyUri}
|
||||
</a>
|
||||
</FieldRow>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Notes span full width */}
|
||||
{notes.length > 0 && (
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
|
||||
{notes.length > 0 && (
|
||||
<Section title={t("detail.notes")}>
|
||||
<div className="flex items-start gap-3">
|
||||
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||
<div className="text-sm space-y-2 flex-1 min-w-0">
|
||||
{notes.map((n, i) => (
|
||||
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
|
||||
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Timestamps span full width */}
|
||||
{(contact.created || contact.updated) && (
|
||||
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1">
|
||||
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
||||
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
||||
</div>
|
||||
)}
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{(contact.created || contact.updated) && (
|
||||
<div className="py-4 text-xs text-muted-foreground space-y-1">
|
||||
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
||||
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatContexts(contexts?: Record<string, boolean>): string {
|
||||
if (!contexts) return "";
|
||||
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
|
||||
}
|
||||
|
||||
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={cn("py-6", className)}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.separator) {
|
||||
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
|
||||
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
||||
|
||||
const categoryStyles: Record<SectionCategory, string> = {
|
||||
contact: "border-l-blue-400 dark:border-l-blue-500",
|
||||
work: "border-l-amber-400 dark:border-l-amber-500",
|
||||
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
||||
personal: "border-l-violet-400 dark:border-l-violet-500",
|
||||
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
||||
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
||||
notes: "border-l-stone-400 dark:border-l-stone-500",
|
||||
};
|
||||
|
||||
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
|
||||
const labels = Object.keys(contexts).filter(k => contexts[k]);
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
|
||||
{labels.join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
|
||||
return (
|
||||
<button
|
||||
@@ -575,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
|
||||
toast.error(failMsg);
|
||||
}
|
||||
}}
|
||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
|
||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } 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, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||
|
||||
interface EmailEntry {
|
||||
address: string;
|
||||
@@ -53,55 +54,73 @@ interface ContactFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
||||
|
||||
const formCategoryStyles: Record<FormCategory, string> = {
|
||||
contact: "border-l-blue-400 dark:border-l-blue-500",
|
||||
work: "border-l-amber-400 dark:border-l-amber-500",
|
||||
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
||||
personal: "border-l-violet-400 dark:border-l-violet-500",
|
||||
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
||||
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
||||
notes: "border-l-stone-400 dark:border-l-stone-500",
|
||||
};
|
||||
|
||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
|
||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
category?: FormCategory;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen || !collapsible);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
|
||||
<section className="py-5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
|
||||
collapsible && "hover:text-muted-foreground cursor-pointer",
|
||||
!collapsible && "cursor-default"
|
||||
"flex items-center gap-2 w-full text-left",
|
||||
collapsible ? "cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
onClick={() => collapsible && setOpen(!open)}
|
||||
tabIndex={collapsible ? 0 : -1}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{collapsible && (
|
||||
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-3 pt-3 pb-1">
|
||||
{(open || !collapsible) && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_PHOTO_DIM = 512;
|
||||
const PHOTO_QUALITY = 0.85;
|
||||
|
||||
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
|
||||
const w = Math.max(1, Math.round(img.width * ratio));
|
||||
const h = Math.max(1, Math.round(img.height * ratio));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("canvas-unsupported"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
|
||||
resolve({ uri, mediaType: "image/jpeg" });
|
||||
};
|
||||
img.onerror = () => reject(new Error("invalid-image"));
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.onerror = () => reject(new Error("read-failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function Select({ value, onChange, children, className }: {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
@@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
}, [contact]);
|
||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||
|
||||
const initialPhotoEntry = useMemo(() => {
|
||||
if (!contact?.media) return null;
|
||||
for (const [key, m] of Object.entries(contact.media)) {
|
||||
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
|
||||
}
|
||||
return null;
|
||||
}, [contact]);
|
||||
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
|
||||
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
|
||||
const [photoError, setPhotoError] = useState<string | null>(null);
|
||||
const [photoUploading, setPhotoUploading] = useState(false);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setPhotoError(t("photo_invalid"));
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setPhotoError(t("photo_too_large"));
|
||||
return;
|
||||
}
|
||||
setPhotoError(null);
|
||||
setPhotoUploading(true);
|
||||
try {
|
||||
const { uri, mediaType } = await processImageFile(file);
|
||||
setPhotoUri(uri);
|
||||
setPhotoMediaType(mediaType);
|
||||
} catch {
|
||||
setPhotoError(t("photo_invalid"));
|
||||
} finally {
|
||||
setPhotoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoRemove = () => {
|
||||
setPhotoUri(undefined);
|
||||
setPhotoMediaType(undefined);
|
||||
setPhotoError(null);
|
||||
};
|
||||
|
||||
const validateEmail = (address: string): boolean => {
|
||||
if (!address.trim()) return true;
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
|
||||
@@ -427,6 +490,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
});
|
||||
}
|
||||
|
||||
const mediaMap: Record<string, ContactMedia> = {};
|
||||
if (contact?.media) {
|
||||
for (const [key, m] of Object.entries(contact.media)) {
|
||||
if (m.kind !== "photo") mediaMap[key] = m;
|
||||
}
|
||||
}
|
||||
if (photoUri) {
|
||||
const photoKey = initialPhotoEntry?.key || "photo";
|
||||
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||
}
|
||||
|
||||
const data: Partial<ContactCard> = {
|
||||
name: { components: nameComponents, isOrdered: true },
|
||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||
@@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
calendarUri: calendarUri.trim() || undefined,
|
||||
schedulingUri: schedulingUri.trim() || undefined,
|
||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
|
||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||
};
|
||||
|
||||
@@ -466,6 +541,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
}
|
||||
};
|
||||
|
||||
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
|
||||
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
@@ -478,38 +556,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4">
|
||||
<div className="px-6 py-4 max-w-3xl">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
|
||||
{/* Address Book Selector */}
|
||||
{addressBooks && addressBooks.length > 1 && (
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
|
||||
<select
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
<div className="flex items-center gap-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
disabled={photoUploading}
|
||||
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
|
||||
title={t("upload_photo")}
|
||||
aria-label={t("upload_photo")}
|
||||
>
|
||||
<Avatar
|
||||
name={previewName || undefined}
|
||||
email={previewEmail || undefined}
|
||||
contactPhotoUri={photoUri}
|
||||
size="lg"
|
||||
className="!w-20 !h-20 !text-xl"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
|
||||
>
|
||||
<Camera className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
|
||||
</span>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handlePhotoSelect}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
|
||||
{photoError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
|
||||
)}
|
||||
{photoUri && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePhotoRemove}
|
||||
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormSection>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("remove_photo")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60">
|
||||
|
||||
{addressBooks && addressBooks.length > 1 && (
|
||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
|
||||
<select
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{/* Name & Identity - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||
<FormSection icon={User} title={t("section_identity")}>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
|
||||
<FormSection icon={Mail} title={t("email")}>
|
||||
<div className="space-y-2">
|
||||
{emails.map((entry, i) => (
|
||||
<div key={i}>
|
||||
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Phone */}
|
||||
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
|
||||
<FormSection icon={Phone} title={t("phone")}>
|
||||
<div className="space-y-2">
|
||||
{phones.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Work & Organization */}
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Addresses - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||
{/* Addresses */}
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
|
||||
<div className="space-y-3">
|
||||
{addresses.map((addr, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
{/* Online Services */}
|
||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
|
||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{onlineServices.map((svc, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Anniversaries */}
|
||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{anniversaries.map((ann, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Personal Info */}
|
||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{personalInfoEntries.map((pi, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Categories */}
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
|
||||
<CategoryComboBox
|
||||
keywordsStr={keywordsStr}
|
||||
onChange={setKeywordsStr}
|
||||
@@ -828,7 +947,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Gender */}
|
||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Calendar */}
|
||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
|
||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
||||
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Notes - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||
{/* Notes */}
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
@@ -876,7 +994,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type TriState = boolean | null;
|
||||
|
||||
interface ListFilters {
|
||||
organization: string;
|
||||
jobTitle: string;
|
||||
location: string;
|
||||
emailDomain: string;
|
||||
birthdayMonth: number | null;
|
||||
hasEmail: TriState;
|
||||
hasPhone: TriState;
|
||||
hasPhoto: TriState;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: ListFilters = {
|
||||
organization: "",
|
||||
jobTitle: "",
|
||||
location: "",
|
||||
emailDomain: "",
|
||||
birthdayMonth: null,
|
||||
hasEmail: null,
|
||||
hasPhone: null,
|
||||
hasPhoto: null,
|
||||
};
|
||||
|
||||
function cycleTri(v: TriState): TriState {
|
||||
return v === null ? true : v === true ? false : null;
|
||||
}
|
||||
|
||||
function countActiveFilters(f: ListFilters): number {
|
||||
let n = 0;
|
||||
if (f.organization.trim()) n++;
|
||||
if (f.jobTitle.trim()) n++;
|
||||
if (f.location.trim()) n++;
|
||||
if (f.emailDomain.trim()) n++;
|
||||
if (f.birthdayMonth !== null) n++;
|
||||
if (f.hasEmail !== null) n++;
|
||||
if (f.hasPhone !== null) n++;
|
||||
if (f.hasPhoto !== null) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function matchTri(actual: boolean, filter: TriState): boolean {
|
||||
if (filter === null) return true;
|
||||
return actual === filter;
|
||||
}
|
||||
|
||||
function getAnniversaryMonth(date: AnniversaryDate): number | null {
|
||||
if (typeof date === "string") {
|
||||
const iso = date.match(/^(\d{4})-(\d{2})/);
|
||||
if (iso) return parseInt(iso[2], 10);
|
||||
const partial = date.match(/^--(\d{2})/);
|
||||
if (partial) return parseInt(partial[1], 10);
|
||||
return null;
|
||||
}
|
||||
if ("month" in date && date.month) return date.month;
|
||||
if ("utc" in date && date.utc) {
|
||||
const d = new Date(date.utc);
|
||||
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; 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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContactListProps {
|
||||
contacts: ContactCard[];
|
||||
selectedContactId: string | null;
|
||||
@@ -20,7 +100,6 @@ interface ContactListProps {
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelectContact: (id: string) => void;
|
||||
onCreateNew: () => void;
|
||||
categoryLabel: string;
|
||||
className?: string;
|
||||
selectedContactIds: Set<string>;
|
||||
onToggleSelection: (id: string) => void;
|
||||
@@ -42,7 +121,6 @@ export function ContactList({
|
||||
onSearchChange,
|
||||
onSelectContact,
|
||||
onCreateNew,
|
||||
categoryLabel,
|
||||
className,
|
||||
selectedContactIds,
|
||||
onToggleSelection,
|
||||
@@ -57,31 +135,94 @@ export function ContactList({
|
||||
onAddContactToGroup,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const locale = useLocale();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||
const activeFilters = countActiveFilters(filters);
|
||||
|
||||
const monthNames = useMemo(() => {
|
||||
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
|
||||
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
|
||||
}, [locale]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return contacts;
|
||||
const lower = searchQuery.toLowerCase();
|
||||
const lower = searchQuery.trim().toLowerCase();
|
||||
const orgLower = filters.organization.trim().toLowerCase();
|
||||
const jobLower = filters.jobTitle.trim().toLowerCase();
|
||||
const locLower = filters.location.trim().toLowerCase();
|
||||
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
|
||||
|
||||
return contacts.filter((c) => {
|
||||
const emails = c.emails ? Object.values(c.emails) : [];
|
||||
const phones = c.phones ? Object.values(c.phones) : [];
|
||||
const orgs = c.organizations ? Object.values(c.organizations) : [];
|
||||
const titles = c.titles ? Object.values(c.titles) : [];
|
||||
const addresses = c.addresses ? Object.values(c.addresses) : [];
|
||||
const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : [];
|
||||
|
||||
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
|
||||
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
|
||||
if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false;
|
||||
|
||||
if (orgLower) {
|
||||
const match = orgs.some((o) => {
|
||||
if (o.name?.toLowerCase().includes(orgLower)) return true;
|
||||
if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true;
|
||||
return false;
|
||||
});
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (jobLower) {
|
||||
if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false;
|
||||
}
|
||||
|
||||
if (locLower) {
|
||||
const match = addresses.some((a) => {
|
||||
const parts: string[] = [];
|
||||
if (a.full) parts.push(a.full);
|
||||
if (a.fullAddress) parts.push(a.fullAddress);
|
||||
if (a.locality) parts.push(a.locality);
|
||||
if (a.region) parts.push(a.region);
|
||||
if (a.country) parts.push(a.country);
|
||||
if (a.postcode) parts.push(a.postcode);
|
||||
if (a.street) parts.push(a.street);
|
||||
if (a.components) {
|
||||
for (const comp of a.components) {
|
||||
if (comp.value) parts.push(comp.value);
|
||||
}
|
||||
}
|
||||
return parts.some((p) => p.toLowerCase().includes(locLower));
|
||||
});
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (domainLower) {
|
||||
const match = emails.some((e) => {
|
||||
const at = e.address?.toLowerCase().split("@");
|
||||
return at && at.length > 1 && at[1].includes(domainLower);
|
||||
});
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (filters.birthdayMonth !== null) {
|
||||
const target = filters.birthdayMonth;
|
||||
const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target);
|
||||
if (!match) return false;
|
||||
}
|
||||
|
||||
if (!lower) return true;
|
||||
const name = getContactDisplayName(c).toLowerCase();
|
||||
const emails = c.emails
|
||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
||||
: [];
|
||||
const phones = c.phones
|
||||
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
|
||||
: [];
|
||||
const org = c.organizations
|
||||
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
|
||||
: [];
|
||||
return (
|
||||
name.includes(lower) ||
|
||||
emails.some((e) => e.includes(lower)) ||
|
||||
phones.some((p) => p.includes(lower)) ||
|
||||
org.some((o) => o.includes(lower))
|
||||
);
|
||||
if (name.includes(lower)) return true;
|
||||
if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
|
||||
if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
|
||||
if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
|
||||
return false;
|
||||
});
|
||||
}, [contacts, searchQuery]);
|
||||
}, [contacts, searchQuery, filters]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
@@ -93,29 +234,214 @@ export function ContactList({
|
||||
|
||||
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
|
||||
|
||||
// Group sorted contacts by first letter of display name. Non-letter
|
||||
// starters (digits, symbols, empty) collect under "#" which sorts last.
|
||||
const groupedSections = useMemo(() => {
|
||||
const collator = new Intl.Collator(locale, { sensitivity: "base" });
|
||||
const groups = new Map<string, ContactCard[]>();
|
||||
for (const contact of sorted) {
|
||||
const name = getContactDisplayName(contact).trim();
|
||||
const first = name.charAt(0);
|
||||
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
|
||||
? first.toLocaleUpperCase(locale)
|
||||
: "#";
|
||||
const bucket = groups.get(letter);
|
||||
if (bucket) bucket.push(contact);
|
||||
else groups.set(letter, [contact]);
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => {
|
||||
if (a === "#") return 1;
|
||||
if (b === "#") return -1;
|
||||
return collator.compare(a, b);
|
||||
})
|
||||
.map(([letter, items]) => ({ letter, items }));
|
||||
}, [sorted, locale]);
|
||||
|
||||
const hasSelection = selectedContactIds.size > 0;
|
||||
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Search header */}
|
||||
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||
{categoryLabel} ({contacts.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
{/* Toolbar: select / search / filter */}
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="px-3 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasSelection) {
|
||||
if (allSelected) onClearSelection();
|
||||
else onSelectAll(sortedIds);
|
||||
} else if (sortedIds.length > 0) {
|
||||
const target = selectedContactId && sortedIds.includes(selectedContactId)
|
||||
? selectedContactId
|
||||
: sortedIds[0];
|
||||
onToggleSelection(target);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
hasSelection
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
|
||||
>
|
||||
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-2 top-1/2 -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>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
filtersOpen || activeFilters > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
title={t("filters.toggle")}
|
||||
aria-label={t("filters.toggle")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!filtersOpen && activeFilters > 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">
|
||||
{activeFilters}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<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("filters.title")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFiltersOpen(false)}
|
||||
className="h-7 w-7"
|
||||
aria-label={t("filters.close")}
|
||||
>
|
||||
<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("filters.organization")}</label>
|
||||
<Input
|
||||
value={filters.organization}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
|
||||
placeholder={t("filters.organization_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
|
||||
<Input
|
||||
value={filters.jobTitle}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
|
||||
placeholder={t("filters.job_title_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
|
||||
<Input
|
||||
value={filters.location}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
|
||||
placeholder={t("filters.location_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
|
||||
<Input
|
||||
value={filters.emailDomain}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
|
||||
placeholder={t("filters.email_domain_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
|
||||
<select
|
||||
value={filters.birthdayMonth ?? ""}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
|
||||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||
aria-label={t("filters.birthday_month")}
|
||||
>
|
||||
<option value="">{t("filters.any_month")}</option>
|
||||
{monthNames.map((name, i) => (
|
||||
<option key={i} value={i + 1}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ToggleChip
|
||||
icon={<Mail className="w-3.5 h-3.5" />}
|
||||
label={t("filters.has_email")}
|
||||
value={filters.hasEmail}
|
||||
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={<Phone className="w-3.5 h-3.5" />}
|
||||
label={t("filters.has_phone")}
|
||||
value={filters.hasPhone}
|
||||
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={<ImageIcon className="w-3.5 h-3.5" />}
|
||||
label={t("filters.has_photo")}
|
||||
value={filters.hasPhoto}
|
||||
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{hasSelection && (
|
||||
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
||||
@@ -166,19 +492,32 @@ export function ContactList({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
||||
{searchQuery ? (
|
||||
{searchQuery || activeFilters > 0 ? (
|
||||
<>
|
||||
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
{t("clear_search")}
|
||||
</Button>
|
||||
{activeFilters > 0 && !searchQuery ? (
|
||||
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
) : (
|
||||
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{searchQuery ? t("empty_search") : t("empty_filtered")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{searchQuery && (
|
||||
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
|
||||
{t("clear_search")}
|
||||
</Button>
|
||||
)}
|
||||
{activeFilters > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -193,8 +532,8 @@ export function ContactList({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{sorted.map((contact) => (
|
||||
(() => {
|
||||
const renderItem = (contact: ContactCard) => (
|
||||
<ContactListItem
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
@@ -221,8 +560,22 @@ export function ContactList({
|
||||
}}
|
||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return groupByLetter ? (
|
||||
<div>
|
||||
{groupedSections.map(({ letter, items }) => (
|
||||
<section key={letter}>
|
||||
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||
{letter}
|
||||
</div>
|
||||
{items.map(renderItem)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>{sorted.map(renderItem)}</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function printContact(contact: ContactCard, displayName?: string): void {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
||||
const name = displayName || getContactDisplayName(contact);
|
||||
const photoUri = getContactPhotoUri(contact);
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
|
||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||
|
||||
const rows: string[] = [];
|
||||
const section = (title: string, items: string[]) => {
|
||||
if (items.length === 0) return;
|
||||
rows.push(`<section><h2>${escapeHtml(title)}</h2><dl>${items.join("")}</dl></section>`);
|
||||
};
|
||||
const row = (label: string, value: string) =>
|
||||
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
|
||||
|
||||
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
|
||||
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
|
||||
section(
|
||||
"Organization",
|
||||
orgs.map(o => {
|
||||
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
|
||||
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
|
||||
return row("Organization", value);
|
||||
}),
|
||||
);
|
||||
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
|
||||
section(
|
||||
"Address",
|
||||
addresses.map(a => {
|
||||
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
|
||||
.map(p => (typeof p === "string" ? p : ""))
|
||||
.filter(Boolean);
|
||||
return row(a.label || "Address", parts.join(", "));
|
||||
}),
|
||||
);
|
||||
section(
|
||||
"Online",
|
||||
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
|
||||
);
|
||||
section(
|
||||
"Anniversary",
|
||||
anniversaries.map(a => {
|
||||
const date = a.date;
|
||||
let formatted = "";
|
||||
if (typeof date === "object" && date !== null) {
|
||||
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
|
||||
else {
|
||||
const parts: string[] = [];
|
||||
const pd = date as PartialDate;
|
||||
if (pd.year) parts.push(String(pd.year));
|
||||
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
|
||||
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
|
||||
formatted = parts.join("-");
|
||||
}
|
||||
} else if (date) {
|
||||
formatted = String(date);
|
||||
}
|
||||
return row(a.kind || "Date", formatted);
|
||||
}),
|
||||
);
|
||||
section("Notes", notes.map(n => row("", n.note || "")));
|
||||
|
||||
const photoTag = photoUri
|
||||
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
|
||||
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(name || "Contact")}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
|
||||
header { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ccc; }
|
||||
.photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
.photo.placeholder { background: #e5e7eb; color: #4b5563; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 600; }
|
||||
h1 { margin: 0; font-size: 22px; }
|
||||
section { margin-top: 20px; }
|
||||
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 0 0 8px; }
|
||||
dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 16px; font-size: 14px; }
|
||||
dt { color: #555; }
|
||||
dd { margin: 0; word-break: break-word; }
|
||||
@media print { body { margin: 16mm; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
${photoTag}
|
||||
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
|
||||
</header>
|
||||
${rows.join("")}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
X,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const attachment = findCalendarAttachment(email);
|
||||
|
||||
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
||||
const isCancellation = method === 'cancel';
|
||||
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
||||
const canCollapse = method === 'reply';
|
||||
const showDetails = !canCollapse || !isCollapsed;
|
||||
const allowsRsvp = method === 'request';
|
||||
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
||||
|
||||
@@ -750,9 +754,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
||||
</div>
|
||||
{canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||
title={isCollapsed ? t('expand') : t('collapse')}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{showDetails && (
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
<div className="lg:flex lg:gap-6">
|
||||
{/* Left: Event info */}
|
||||
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showDetails && (
|
||||
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
|
||||
{canRespond && (
|
||||
<>
|
||||
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||
// Add visual feedback for checked state
|
||||
@@ -302,7 +303,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function EmailList({
|
||||
batchMarkAsRead,
|
||||
batchDelete,
|
||||
batchMoveToMailbox,
|
||||
batchArchive,
|
||||
batchMarkAsSpam,
|
||||
batchUndoSpam,
|
||||
loadMoreEmails,
|
||||
@@ -497,12 +498,12 @@ export function EmailList({
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
if (!onArchive) return;
|
||||
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
|
||||
for (const email of selected) {
|
||||
await onArchive(email);
|
||||
if (!client) return;
|
||||
try {
|
||||
await batchArchive(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to batch archive:', error);
|
||||
}
|
||||
clearSelection();
|
||||
}}
|
||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||
onBatchMarkAsSpam={async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
|
||||
import ReactDOM from "react-dom";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -2322,7 +2322,7 @@ export function EmailViewer({
|
||||
let blockedExternalContent = false;
|
||||
|
||||
// Use shared sanitization config as base (more secure)
|
||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||
const sanitizeConfig = { ...EMAIL_IFRAME_SANITIZE_CONFIG };
|
||||
|
||||
// Check if sender is trusted (localStorage list or address book)
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
@@ -2438,7 +2438,7 @@ export function EmailViewer({
|
||||
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
}
|
||||
);
|
||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG);
|
||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (smimeDecryptedText) {
|
||||
@@ -2446,7 +2446,7 @@ export function EmailViewer({
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG);
|
||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (tnefText) {
|
||||
@@ -2454,7 +2454,7 @@ export function EmailViewer({
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG);
|
||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (embeddedEmailText) {
|
||||
@@ -2603,7 +2603,7 @@ export function EmailViewer({
|
||||
return `<!DOCTYPE html>
|
||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
@@ -2880,7 +2880,7 @@ export function EmailViewer({
|
||||
<>
|
||||
{/* Left: Reply actions */}
|
||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -3628,7 +3628,7 @@ export function EmailViewer({
|
||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
{/* Back button (for below-subject mode on tablet) */}
|
||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -3676,8 +3676,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Date/time on the right of subject row */}
|
||||
<div className="flex-shrink-0 text-right">
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||
<div className="hidden sm:block flex-shrink-0 text-right">
|
||||
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
@@ -4484,6 +4484,17 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Date/time + size on the right (mobile) */}
|
||||
<div className="sm:hidden flex-shrink-0 text-right ml-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
{email.size > 0 && (
|
||||
<div className="text-xs text-muted-foreground/60">
|
||||
{formatFileSize(email.size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -133,7 +133,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !resolvedColorTag && "shadow-sm",
|
||||
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
@@ -323,7 +324,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
@@ -487,7 +488,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50",
|
||||
@@ -725,7 +727,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
|
||||
@@ -7,22 +7,21 @@ import { useConfig } from '@/hooks/use-config';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { SpamSiegeGame } from './spam-siege-game';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
|
||||
export function AdvancedSettings() {
|
||||
export function AboutDataSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const tCommon = useTranslations('common');
|
||||
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||
useSettingsStore();
|
||||
const { settingsSyncEnabled } = useConfig();
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
const logoClickCount = useRef(0);
|
||||
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -86,7 +85,6 @@ export function AdvancedSettings() {
|
||||
return (
|
||||
<>
|
||||
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
|
||||
{/* About */}
|
||||
<div className="rounded-lg border border-border bg-card p-5 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||
@@ -122,88 +120,48 @@ export function AdvancedSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Debug Mode */}
|
||||
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
|
||||
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{settingsSyncEnabled && (
|
||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Debug Categories */}
|
||||
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
|
||||
{ALL_DEBUG_CATEGORIES.map((cat) => (
|
||||
<SettingItem
|
||||
key={cat.id}
|
||||
label={t(`debug_categories.${cat.labelKey}`)}
|
||||
description={t(`debug_categories.${cat.labelKey}_description`)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={debugCategories?.[cat.id] !== false}
|
||||
onChange={(checked) => {
|
||||
updateSetting('debugCategories', {
|
||||
...debugCategories,
|
||||
[cat.id]: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Sync */}
|
||||
{settingsSyncEnabled && (
|
||||
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Sender Favicons (Experimental) */}
|
||||
<SettingItem label={t('sender_favicons.label')} description={t('sender_favicons.description')}>
|
||||
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
{/* Export Settings */}
|
||||
{isFeatureEnabled('settingsExportEnabled') && (
|
||||
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
{t('export_settings.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Import Settings */}
|
||||
{isFeatureEnabled('settingsExportEnabled') && (
|
||||
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||
{t('import_settings.button')}
|
||||
{isFeatureEnabled('settingsExportEnabled') && (
|
||||
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
{t('export_settings.button')}
|
||||
</Button>
|
||||
</>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Reset Settings */}
|
||||
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
|
||||
<Button
|
||||
variant={showResetConfirm ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
{isFeatureEnabled('settingsExportEnabled') && (
|
||||
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleImport}>
|
||||
{t('import_settings.button')}
|
||||
</Button>
|
||||
</>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
|
||||
<Button
|
||||
variant={showResetConfirm ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { useSettingsStore, type Density } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||
@@ -66,16 +64,15 @@ function DensityPreview({ density }: { density: Density }) {
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Theme */}
|
||||
<SettingItem label={t('theme.label')} description={t('theme.description')}>
|
||||
<RadioGroup
|
||||
value={theme}
|
||||
@@ -88,12 +85,6 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Language */}
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
{/* Font Size */}
|
||||
{!isSettingHidden('fontSize') && (
|
||||
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
|
||||
<RadioGroup
|
||||
@@ -108,7 +99,6 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Density */}
|
||||
{!isSettingHidden('density') && (
|
||||
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
|
||||
<RadioGroup
|
||||
@@ -127,64 +117,6 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Toolbar Position */}
|
||||
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
|
||||
<RadioGroup
|
||||
value={toolbarPosition}
|
||||
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
|
||||
options={[
|
||||
{ value: 'top', label: t('toolbar_position.top') },
|
||||
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Toolbar Labels */}
|
||||
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showToolbarLabels}
|
||||
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Hide Account Switcher */}
|
||||
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideAccountSwitcher}
|
||||
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Show Rail Account List */}
|
||||
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showRailAccountList}
|
||||
onChange={(checked) => updateSetting('showRailAccountList', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Colorful Sidebar Icons */}
|
||||
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
|
||||
<ToggleSwitch
|
||||
checked={colorfulSidebarIcons}
|
||||
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Unified Mailbox */}
|
||||
{accounts.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Animations */}
|
||||
{!isSettingHidden('animationsEnabled') && (
|
||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||
<ToggleSwitch
|
||||
@@ -194,7 +126,10 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Restart Tour */}
|
||||
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
|
||||
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Mail, X } from 'lucide-react';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
|
||||
setDefaultMailStatus('success');
|
||||
}
|
||||
} catch {
|
||||
setDefaultMailStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||
<ToggleSwitch
|
||||
checked={autoSelectReplyIdentity}
|
||||
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
{attachmentReminderEnabled && (
|
||||
<div className="py-3 border-b border-border space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachmentReminderKeywords.map((kw) => (
|
||||
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('attachment_reminder.remove')}
|
||||
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newKeyword.trim().toLowerCase();
|
||||
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
||||
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder={t('attachment_reminder.add_placeholder')}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newKeyword.trim()}
|
||||
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('attachment_reminder.add')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleSetDefaultMailProgram}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
|
||||
</button>
|
||||
{defaultMailStatus === 'success' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
|
||||
)}
|
||||
{defaultMailStatus === 'error' && (
|
||||
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem } from "./settings-section";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
export function ContactsSettings() {
|
||||
@@ -20,6 +21,8 @@ export function ContactsSettings() {
|
||||
supportsSync,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
const individuals = contacts.filter(c => c.kind !== "group");
|
||||
@@ -55,6 +58,16 @@ export function ContactsSettings() {
|
||||
title={tSettings("title")}
|
||||
description={tSettings("description")}
|
||||
>
|
||||
<SettingItem
|
||||
label={tSettings("group_by_letter_label")}
|
||||
description={tSettings("group_by_letter_description")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={groupContactsByLetter}
|
||||
onChange={(checked) => updateSetting("groupContactsByLetter", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={tSettings("import_label")}
|
||||
description={tSettings("import_description")}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
export function ContentSendersSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
const {
|
||||
externalContentPolicy,
|
||||
emailAlwaysLightMode,
|
||||
trustedSenders,
|
||||
trustedSendersAddressBook,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { trustedSenderEmails } = useContactStore();
|
||||
|
||||
const getTrustedSendersCount = () => {
|
||||
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
|
||||
if (count === 0) return t('trusted_senders.count_zero');
|
||||
if (count === 1) return t('trusted_senders.count_one');
|
||||
return t('trusted_senders.count_other', { count });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{!isSettingHidden('externalContentPolicy') && (
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
onChange={(value) =>
|
||||
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
|
||||
}
|
||||
options={[
|
||||
{ value: 'ask', label: t('external_content.ask') },
|
||||
{ value: 'block', label: t('external_content.block') },
|
||||
{ value: 'allow', label: t('external_content.allow') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
|
||||
<ToggleSwitch
|
||||
checked={emailAlwaysLightMode}
|
||||
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
|
||||
<button
|
||||
onClick={() => setShowTrustedModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||
<ToggleSwitch
|
||||
checked={trustedSendersAddressBook}
|
||||
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<TrustedSendersModal
|
||||
isOpen={showTrustedModal}
|
||||
onClose={() => setShowTrustedModal(false)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
export function DebugSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const { debugMode, debugCategories, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
|
||||
if (isSettingHidden('debugMode') || !isFeatureEnabled('debugModeEnabled')) {
|
||||
return (
|
||||
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
|
||||
<p className="text-sm text-muted-foreground py-2">{t('debug_mode.description')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
|
||||
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
|
||||
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
{debugMode && (
|
||||
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
|
||||
{ALL_DEBUG_CATEGORIES.map((cat) => (
|
||||
<SettingItem
|
||||
key={cat.id}
|
||||
label={t(`debug_categories.${cat.labelKey}`)}
|
||||
description={t(`debug_categories.${cat.labelKey}_description`)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={debugCategories?.[cat.id] !== false}
|
||||
onChange={(checked) => {
|
||||
updateSetting('debugCategories', {
|
||||
...debugCategories,
|
||||
[cat.id]: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('language.label')} description={t('language.description')}>
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
|
||||
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||
];
|
||||
|
||||
function MailLayoutPreview({
|
||||
value,
|
||||
t,
|
||||
}: {
|
||||
value: MailLayout;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const isSplit = value === 'split';
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex h-28">
|
||||
<div className="w-11 border-r border-border bg-muted/40" />
|
||||
|
||||
{isSplit ? (
|
||||
<>
|
||||
<div className="w-28 border-r border-border bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium text-foreground">{row.sender}</div>
|
||||
<div className="truncate text-muted-foreground">{row.subject}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 bg-background px-3 py-2">
|
||||
<div className="h-2.5 w-20 rounded bg-foreground/10" />
|
||||
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 bg-background px-2 py-2">
|
||||
<div className="space-y-1.5">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[10px]',
|
||||
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
<span className="font-medium">{row.sender}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{!isSettingHidden('mailLayout') && (
|
||||
<SettingItem label={tEmail('mail_layout.label')} description={tEmail('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||
<div className="w-[22rem] max-w-full">
|
||||
<RadioGroup
|
||||
value={mailLayout}
|
||||
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
|
||||
options={[
|
||||
{ value: 'split', label: tEmail('mail_layout.split') },
|
||||
{ value: 'focus', label: tEmail('mail_layout.focus') },
|
||||
]}
|
||||
/>
|
||||
<MailLayoutPreview value={mailLayout} t={tEmail} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
|
||||
<RadioGroup
|
||||
value={toolbarPosition}
|
||||
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
|
||||
options={[
|
||||
{ value: 'top', label: t('toolbar_position.top') },
|
||||
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showToolbarLabels}
|
||||
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideAccountSwitcher}
|
||||
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showRailAccountList}
|
||||
onChange={(checked) => updateSetting('showRailAccountList', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
|
||||
<ToggleSwitch
|
||||
checked={colorfulSidebarIcons}
|
||||
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
|
||||
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
|
||||
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
|
||||
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||
];
|
||||
|
||||
function MailLayoutPreview({
|
||||
value,
|
||||
t,
|
||||
}: {
|
||||
value: MailLayout;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const isSplit = value === 'split';
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex h-28">
|
||||
<div className="w-11 border-r border-border bg-muted/40" />
|
||||
|
||||
{isSplit ? (
|
||||
<>
|
||||
<div className="w-28 border-r border-border bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium text-foreground">{row.sender}</div>
|
||||
<div className="truncate text-muted-foreground">{row.subject}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 bg-background px-3 py-2">
|
||||
<div className="h-2.5 w-20 rounded bg-foreground/10" />
|
||||
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 bg-background px-2 py-2">
|
||||
<div className="space-y-1.5">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[10px]',
|
||||
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
<span className="font-medium">{row.sender}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||
<span className="text-muted-foreground/80">{row.preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailSettings() {
|
||||
export function ReadingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
const [isReorganizing, setIsReorganizing] = useState(false);
|
||||
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
|
||||
setDefaultMailStatus('success');
|
||||
}
|
||||
} catch {
|
||||
setDefaultMailStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
markAsReadDelay,
|
||||
deleteAction,
|
||||
@@ -120,33 +25,17 @@ export function EmailSettings() {
|
||||
showPreview,
|
||||
mailLayout,
|
||||
disableThreading,
|
||||
autoSelectReplyIdentity,
|
||||
plainTextMode,
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
mailAttachmentAction,
|
||||
attachmentPosition,
|
||||
emailAlwaysLightMode,
|
||||
archiveMode,
|
||||
hoverActions,
|
||||
hoverActionsMode,
|
||||
hoverActionsCorner,
|
||||
trustedSenders,
|
||||
trustedSendersAddressBook,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
hideInlineImageAttachments,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { trustedSenderEmails } = useContactStore();
|
||||
|
||||
// Get count label for trusted senders button
|
||||
const getTrustedSendersCount = () => {
|
||||
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
|
||||
if (count === 0) return t('trusted_senders.count_zero');
|
||||
if (count === 1) return t('trusted_senders.count_one');
|
||||
return t('trusted_senders.count_other', { count });
|
||||
};
|
||||
|
||||
const isFocusedLayout = mailLayout === 'focus';
|
||||
|
||||
@@ -163,8 +52,6 @@ export function EmailSettings() {
|
||||
|
||||
try {
|
||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||
|
||||
// Fetch all emails in the root archive mailbox
|
||||
const emails = await client.getEmailsInMailbox(archiveId);
|
||||
let movedCount = 0;
|
||||
|
||||
@@ -173,10 +60,8 @@ export function EmailSettings() {
|
||||
const year = emailDate.getFullYear().toString();
|
||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
|
||||
// Re-read mailboxes from store each iteration in case new ones were created
|
||||
let currentMailboxes = useEmailStore.getState().mailboxes;
|
||||
|
||||
// Find or create year subfolder
|
||||
let yearMailbox = currentMailboxes.find(
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
@@ -190,7 +75,6 @@ export function EmailSettings() {
|
||||
await client.moveEmail(email.id, yearMailbox.id);
|
||||
movedCount++;
|
||||
} else {
|
||||
// month mode
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = currentMailboxes.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
@@ -215,7 +99,6 @@ export function EmailSettings() {
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Mark as Read */}
|
||||
{!isSettingHidden('markAsReadDelay') && (
|
||||
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
|
||||
<Select
|
||||
@@ -231,7 +114,6 @@ export function EmailSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Delete Action */}
|
||||
{!isSettingHidden('deleteAction') && (
|
||||
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -253,7 +135,6 @@ export function EmailSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Archive Mode */}
|
||||
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
@@ -287,7 +168,6 @@ export function EmailSettings() {
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Permanently Delete Junk */}
|
||||
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
|
||||
<ToggleSwitch
|
||||
checked={permanentlyDeleteJunk}
|
||||
@@ -295,23 +175,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{!isSettingHidden('mailLayout') && (
|
||||
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||
<div className="w-[22rem] max-w-full">
|
||||
<RadioGroup
|
||||
value={mailLayout}
|
||||
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
|
||||
options={[
|
||||
{ value: 'split', label: t('mail_layout.split') },
|
||||
{ value: 'focus', label: t('mail_layout.focus') },
|
||||
]}
|
||||
/>
|
||||
<MailLayoutPreview value={mailLayout} t={t} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Show Preview */}
|
||||
{!isSettingHidden('showPreview') && (
|
||||
<SettingItem
|
||||
label={t('show_preview.label')}
|
||||
@@ -322,7 +185,6 @@ export function EmailSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Disable Thread Grouping */}
|
||||
<SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}>
|
||||
<ToggleSwitch
|
||||
checked={disableThreading}
|
||||
@@ -330,7 +192,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Plain Text Mode */}
|
||||
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
|
||||
<ToggleSwitch
|
||||
checked={plainTextMode}
|
||||
@@ -338,71 +199,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||
<ToggleSwitch
|
||||
checked={autoSelectReplyIdentity}
|
||||
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Attachment Reminder */}
|
||||
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
{attachmentReminderEnabled && (
|
||||
<div className="py-3 border-b border-border space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachmentReminderKeywords.map((kw) => (
|
||||
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('attachment_reminder.remove')}
|
||||
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newKeyword.trim().toLowerCase();
|
||||
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
||||
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder={t('attachment_reminder.add_placeholder')}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newKeyword.trim()}
|
||||
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('attachment_reminder.add')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hide inline images from attachment list */}
|
||||
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideInlineImageAttachments}
|
||||
@@ -410,7 +206,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Quick Hover Actions */}
|
||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||
<div className="py-3 border-b border-border space-y-3">
|
||||
<div>
|
||||
@@ -443,7 +238,6 @@ export function EmailSettings() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hover Actions Display Mode */}
|
||||
<div className="pt-2 space-y-2">
|
||||
<label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -465,7 +259,6 @@ export function EmailSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner Selection (only when floating) */}
|
||||
{hoverActionsMode === 'floating' && (
|
||||
<div className="pt-1 space-y-2">
|
||||
<label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label>
|
||||
@@ -513,7 +306,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Emails Per Page */}
|
||||
{!isSettingHidden('emailsPerPage') && (
|
||||
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
|
||||
<Select
|
||||
@@ -528,75 +320,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Always Light Mode for Emails */}
|
||||
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
|
||||
<ToggleSwitch
|
||||
checked={emailAlwaysLightMode}
|
||||
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* External Content */}
|
||||
{!isSettingHidden('externalContentPolicy') && (
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
onChange={(value) =>
|
||||
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
|
||||
}
|
||||
options={[
|
||||
{ value: 'ask', label: t('external_content.ask') },
|
||||
{ value: 'block', label: t('external_content.block') },
|
||||
{ value: 'allow', label: t('external_content.allow') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Default Mail Program */}
|
||||
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleSetDefaultMailProgram}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
|
||||
</button>
|
||||
{defaultMailStatus === 'success' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
|
||||
)}
|
||||
{defaultMailStatus === 'error' && (
|
||||
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders */}
|
||||
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
|
||||
<button
|
||||
onClick={() => setShowTrustedModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders - address book storage */}
|
||||
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||
<ToggleSwitch
|
||||
checked={trustedSendersAddressBook}
|
||||
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders Modal */}
|
||||
<TrustedSendersModal
|
||||
isOpen={showTrustedModal}
|
||||
onClose={() => setShowTrustedModal(false)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { getMailboxPath } from "@/lib/utils";
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
@@ -30,7 +31,7 @@ interface UseMailboxDropReturn {
|
||||
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
|
||||
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore();
|
||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||
|
||||
// Determine if this is a valid drop target
|
||||
@@ -106,13 +107,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
|
||||
const emailIds: string[] = JSON.parse(emailIdsJson);
|
||||
|
||||
// Get the destination mailbox ID (use originalId for shared folders)
|
||||
const destinationId = mailbox.originalId || mailbox.id;
|
||||
|
||||
// Move emails one by one (store handles counter updates)
|
||||
for (const emailId of emailIds) {
|
||||
await moveToMailbox(client, emailId, destinationId);
|
||||
}
|
||||
// Move in a single bulk JMAP request (store handles counter updates).
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
|
||||
// Clear selection if any selected emails were moved
|
||||
if (emailIds.some(id => selectedEmailIds.has(id))) {
|
||||
@@ -122,15 +118,15 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Refresh the current mailbox view
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
|
||||
// Call success callback if provided, otherwise use fallback
|
||||
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(emailIds.length, mailbox.name);
|
||||
onSuccess(emailIds.length, mailboxPath);
|
||||
} else {
|
||||
// Fallback for backward compatibility
|
||||
if (emailIds.length === 1) {
|
||||
toast.success("Email moved", `Moved to ${mailbox.name}`);
|
||||
toast.success("Email moved", `Moved to ${mailboxPath}`);
|
||||
} else {
|
||||
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
|
||||
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
|
||||
const valid = isValidTarget();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
|
||||
// Tailwind v4 breakpoints
|
||||
@@ -12,26 +12,30 @@ const BREAKPOINTS = {
|
||||
"2xl": 1536,
|
||||
} as const;
|
||||
|
||||
const getMediaQueryServerSnapshot = () => false;
|
||||
|
||||
/**
|
||||
* SSR-safe media query hook
|
||||
* Returns false during SSR to prevent hydration mismatch
|
||||
* SSR-safe media query hook. On SSR and the first hydration pass we report
|
||||
* `false`; on all subsequent client renders (including client-side navigation
|
||||
* remounts) we read `matchMedia` synchronously, so components don't flash
|
||||
* through a one-frame "mobile" layout on desktop.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => {
|
||||
const mq = window.matchMedia(query);
|
||||
mq.addEventListener("change", callback);
|
||||
return () => mq.removeEventListener("change", callback);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
setMatches(mediaQuery.matches);
|
||||
const getSnapshot = useCallback(
|
||||
() => window.matchMedia(query).matches,
|
||||
[query],
|
||||
);
|
||||
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface DragCreateState {
|
||||
@@ -12,9 +13,13 @@ interface DragCreateState {
|
||||
endMinutes: number;
|
||||
}
|
||||
|
||||
export type ResizeEdge = "top" | "bottom";
|
||||
|
||||
interface ResizeState {
|
||||
eventId: string;
|
||||
topPx: number;
|
||||
heightPx: number;
|
||||
startMinutes: number;
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
@@ -40,6 +45,7 @@ interface UseTimeGridInteractionsOptions {
|
||||
created: string;
|
||||
error: string;
|
||||
};
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function useTimeGridInteractions({
|
||||
@@ -47,6 +53,7 @@ export function useTimeGridInteractions({
|
||||
calendars,
|
||||
onCreateRange,
|
||||
errorMessages,
|
||||
isMobile,
|
||||
}: UseTimeGridInteractionsOptions) {
|
||||
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
|
||||
const raw = ((clientY - containerTop) / hourHeight) * 60;
|
||||
@@ -72,6 +79,7 @@ export function useTimeGridInteractions({
|
||||
dayKey: string,
|
||||
dayDate: Date,
|
||||
) => {
|
||||
if (isMobile) return;
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
|
||||
|
||||
@@ -82,7 +90,7 @@ export function useTimeGridInteractions({
|
||||
dayKey, dayDate, startMinutes: minutes,
|
||||
pointerId: e.pointerId, startY: e.clientY, captured: false,
|
||||
};
|
||||
}, [snapToMinutes]);
|
||||
}, [snapToMinutes, isMobile]);
|
||||
|
||||
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
@@ -130,43 +138,65 @@ export function useTimeGridInteractions({
|
||||
// --- Resize ---
|
||||
const resizeRef = useRef<{
|
||||
eventId: string;
|
||||
edge: ResizeEdge;
|
||||
startY: number;
|
||||
originalStartMinutes: number;
|
||||
originalDurationMinutes: number;
|
||||
originalHeightPx: number;
|
||||
pointerId: number;
|
||||
} | null>(null);
|
||||
|
||||
const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null);
|
||||
|
||||
const computeResize = useCallback((
|
||||
ref: NonNullable<typeof resizeRef.current>,
|
||||
clientY: number,
|
||||
): { startMinutes: number; durationMinutes: number } => {
|
||||
const deltaY = clientY - ref.startY;
|
||||
const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15;
|
||||
const originalEnd = ref.originalStartMinutes + ref.originalDurationMinutes;
|
||||
|
||||
if (ref.edge === "bottom") {
|
||||
const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes);
|
||||
return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration };
|
||||
}
|
||||
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0.
|
||||
let newStart = ref.originalStartMinutes + deltaMinutes;
|
||||
newStart = Math.max(0, Math.min(originalEnd - 15, newStart));
|
||||
return { startMinutes: newStart, durationMinutes: originalEnd - newStart };
|
||||
}, [hourHeight]);
|
||||
|
||||
const handleResizePointerDown = useCallback((
|
||||
eventId: string,
|
||||
edge: ResizeEdge,
|
||||
originalStartMinutes: number,
|
||||
originalDurationMinutes: number,
|
||||
e: PointerEvent,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
|
||||
resizeRef.current = {
|
||||
eventId,
|
||||
edge,
|
||||
startY: e.clientY,
|
||||
originalStartMinutes,
|
||||
originalDurationMinutes,
|
||||
originalHeightPx,
|
||||
pointerId: e.pointerId,
|
||||
};
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [hourHeight]);
|
||||
}, []);
|
||||
|
||||
const handleResizePointerMove = useCallback((e: PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
|
||||
const deltaY = e.clientY - resizeRef.current.startY;
|
||||
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY);
|
||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||
const snappedHeight = (newDurationMinutes / 60) * hourHeight;
|
||||
|
||||
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes });
|
||||
}, [hourHeight]);
|
||||
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
|
||||
setResizeVisual({
|
||||
eventId: resizeRef.current.eventId,
|
||||
topPx: (startMinutes / 60) * hourHeight,
|
||||
heightPx: (durationMinutes / 60) * hourHeight,
|
||||
startMinutes,
|
||||
durationMinutes,
|
||||
});
|
||||
}, [hourHeight, computeResize]);
|
||||
|
||||
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
|
||||
const resize = resizeRef.current;
|
||||
@@ -179,11 +209,11 @@ export function useTimeGridInteractions({
|
||||
|
||||
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
|
||||
|
||||
const deltaY = e.clientY - resize.startY;
|
||||
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
|
||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||
const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
|
||||
|
||||
if (newDurationMinutes === resize.originalDurationMinutes) {
|
||||
const startChanged = newStartMinutes !== resize.originalStartMinutes;
|
||||
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
|
||||
if (!startChanged && !durationChanged) {
|
||||
setResizeVisual(null);
|
||||
return;
|
||||
}
|
||||
@@ -205,14 +235,21 @@ export function useTimeGridInteractions({
|
||||
try {
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined);
|
||||
const updates: { start?: string; duration: string } = { duration: dur };
|
||||
if (startChanged && event?.start) {
|
||||
// Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone).
|
||||
const deltaMinutes = newStartMinutes - resize.originalStartMinutes;
|
||||
const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000);
|
||||
updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
}
|
||||
await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined);
|
||||
} catch (error) {
|
||||
debug.error("Failed to resize event:", resize.eventId, error);
|
||||
toast.error(errorMessages.resize);
|
||||
} finally {
|
||||
setResizeVisual(null);
|
||||
}
|
||||
}, [hourHeight, errorMessages.resize]);
|
||||
}, [computeResize, errorMessages.resize]);
|
||||
|
||||
// --- Click / Double-click / Quick-create ---
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -317,14 +354,18 @@ export function useTimeGridInteractions({
|
||||
const minutes = snapDragMinutes(e);
|
||||
const newStart = new Date(day);
|
||||
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
|
||||
// Emit `start` as a floating wall-clock in the event's own timeZone.
|
||||
// If we used browser-local, the server would reinterpret it in event.timeZone
|
||||
// and shift the event by the offset between the two zones.
|
||||
const eventTimeZone = event?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const newStartISO = formatIsoInTimeZone(newStart, eventTimeZone);
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) {
|
||||
toast.error(errorMessages.move);
|
||||
return;
|
||||
}
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
} catch {
|
||||
|
||||
@@ -219,10 +219,26 @@ export function layoutOverlappingEvents(
|
||||
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
|
||||
});
|
||||
|
||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||
const result: TimedEventLayout[] = [];
|
||||
let columns: { event: CalendarEvent; end: number }[][] = [];
|
||||
let clusterStart = 0;
|
||||
let clusterMaxEnd = 0;
|
||||
|
||||
const flushCluster = () => {
|
||||
const total = columns.length;
|
||||
for (let i = clusterStart; i < result.length; i++) {
|
||||
result[i].totalColumns = total;
|
||||
}
|
||||
};
|
||||
|
||||
for (const event of sorted) {
|
||||
if (columns.length > 0 && event.startMinutes >= clusterMaxEnd) {
|
||||
flushCluster();
|
||||
clusterStart = result.length;
|
||||
columns = [];
|
||||
clusterMaxEnd = 0;
|
||||
}
|
||||
|
||||
let placed = false;
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
if (columns[col].every(e => e.end <= event.startMinutes)) {
|
||||
@@ -236,10 +252,10 @@ export function layoutOverlappingEvents(
|
||||
columns.push([{ event: event.event, end: event.endMinutes }]);
|
||||
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
|
||||
}
|
||||
clusterMaxEnd = Math.max(clusterMaxEnd, event.endMinutes);
|
||||
}
|
||||
|
||||
const total = columns.length;
|
||||
result.forEach(r => r.totalColumns = total);
|
||||
flushCluster();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -256,3 +272,22 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri
|
||||
export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined {
|
||||
return Object.keys(event.calendarIds || {})[0];
|
||||
}
|
||||
|
||||
export function formatIsoInTimeZone(date: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== "literal") map[part.type] = part.value;
|
||||
}
|
||||
const hour = map.hour === "24" ? "00" : map.hour;
|
||||
return `${map.year}-${map.month}-${map.day}T${hour}:${map.minute}:${map.second}`;
|
||||
}
|
||||
|
||||
@@ -269,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
if (mode === 'single') {
|
||||
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId);
|
||||
return;
|
||||
}
|
||||
for (const { id, receivedAt } of emails) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (!email) continue;
|
||||
const d = new Date(receivedAt);
|
||||
const year = d.getFullYear().toString();
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId);
|
||||
if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId);
|
||||
let destId = yearBox.id;
|
||||
if (mode === 'month') {
|
||||
let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id);
|
||||
if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id);
|
||||
destId = monthBox.id;
|
||||
}
|
||||
email.mailboxIds = { [destId]: true };
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||
|
||||
@@ -36,6 +36,26 @@ export function sanitizeEmailHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize config for emails rendered inside a sandboxed iframe.
|
||||
* Allows <style> tags because CSS is scoped to the iframe document and
|
||||
* cannot leak into the host app. Scripts are still blocked by the sandbox
|
||||
* attribute (no allow-scripts). Use ONLY for iframe-rendered content –
|
||||
* never for content rendered into the main DOM.
|
||||
*/
|
||||
export const EMAIL_IFRAME_SANITIZE_CONFIG = {
|
||||
...EMAIL_SANITIZE_CONFIG,
|
||||
FORBID_TAGS: EMAIL_SANITIZE_CONFIG.FORBID_TAGS.filter((t) => t !== 'style'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize email HTML for rendering inside a sandboxed iframe.
|
||||
* Preserves <style> tags so the email's own CSS is applied.
|
||||
*/
|
||||
export function sanitizeEmailHtmlForIframe(html: string): string {
|
||||
return DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature with stricter rules
|
||||
* Only allows basic formatting, no external resources
|
||||
|
||||
@@ -85,6 +85,13 @@ export interface IJMAPClient {
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
accountId?: string,
|
||||
): Promise<void>;
|
||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||
emptyMailbox(mailboxId: string): Promise<number>;
|
||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||
|
||||
@@ -1207,6 +1207,113 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
if (mode === 'single') {
|
||||
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId);
|
||||
return;
|
||||
}
|
||||
|
||||
type Dest = { year: string; month?: string };
|
||||
const destFor = new Map<string, Dest>();
|
||||
for (const e of emails) {
|
||||
const d = new Date(e.receivedAt);
|
||||
const year = d.getFullYear().toString();
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
|
||||
}
|
||||
|
||||
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
|
||||
const yearIdFor = new Map<string, string>();
|
||||
const monthIdFor = new Map<string, string>();
|
||||
const createEntries: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
const findExisting = (name: string, parentId: string) =>
|
||||
existingMailboxes.find(m =>
|
||||
m.accountId === targetAccountId &&
|
||||
m.name === name &&
|
||||
(m.parentId === parentId || m.parentId === (parentId.startsWith('#') ? undefined : parentId)),
|
||||
);
|
||||
|
||||
for (const dest of destFor.values()) {
|
||||
if (!yearIdFor.has(dest.year)) {
|
||||
const existing = findExisting(dest.year, archiveMailboxId);
|
||||
if (existing) {
|
||||
yearIdFor.set(dest.year, existing.originalId || existing.id);
|
||||
} else {
|
||||
const cid = `year-${dest.year}`;
|
||||
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
|
||||
yearIdFor.set(dest.year, `#${cid}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'month' && dest.month) {
|
||||
const monthKey = `${dest.year}/${dest.month}`;
|
||||
if (!monthIdFor.has(monthKey)) {
|
||||
const yearRef = yearIdFor.get(dest.year)!;
|
||||
// Only look up existing month folders under real (non-creation-ref) year ids.
|
||||
const existingMonth = yearRef.startsWith('#')
|
||||
? undefined
|
||||
: findExisting(dest.month, yearRef);
|
||||
if (existingMonth) {
|
||||
monthIdFor.set(monthKey, existingMonth.originalId || existingMonth.id);
|
||||
} else {
|
||||
const cid = `month-${dest.year}-${dest.month}`;
|
||||
createEntries[cid] = { name: dest.month, parentId: yearRef };
|
||||
monthIdFor.set(monthKey, `#${cid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||||
for (const [emailId, dest] of destFor.entries()) {
|
||||
const destId = mode === 'month' && dest.month
|
||||
? monthIdFor.get(`${dest.year}/${dest.month}`)!
|
||||
: yearIdFor.get(dest.year)!;
|
||||
updates[emailId] = { mailboxIds: { [destId]: true } };
|
||||
}
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
const hasCreates = Object.keys(createEntries).length > 0;
|
||||
if (hasCreates) {
|
||||
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
|
||||
}
|
||||
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
if (hasCreates) {
|
||||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||||
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
|
||||
const failures = notCreated ? Object.entries(notCreated) : [];
|
||||
if (failures.length > 0) {
|
||||
const [cid, err] = failures[0];
|
||||
const parts = [err.type || 'unknown'];
|
||||
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err.description) parts.push(err.description);
|
||||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const emailIdx = hasCreates ? 1 : 0;
|
||||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||||
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
|
||||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||||
if (emailFailures.length > 0) {
|
||||
const [id, err] = emailFailures[0];
|
||||
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
@@ -1316,7 +1423,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notCreated?.[createId]) {
|
||||
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`);
|
||||
const err = result.notCreated[createId];
|
||||
const details = [err.type || 'unknown error'];
|
||||
if (Array.isArray(err.properties) && err.properties.length > 0) {
|
||||
details.push(`properties=[${err.properties.join(', ')}]`);
|
||||
}
|
||||
if (err.description) details.push(err.description);
|
||||
throw new Error(`Failed to create mailbox: ${details.join(' – ')}`);
|
||||
}
|
||||
|
||||
const created = result?.created?.[createId];
|
||||
|
||||
@@ -429,4 +429,23 @@ export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
|
||||
|
||||
traverse(nodes);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getMailboxPath(
|
||||
mailbox: Pick<Mailbox, 'id' | 'name' | 'parentId'>,
|
||||
allMailboxes: Array<Pick<Mailbox, 'id' | 'name' | 'parentId'>>,
|
||||
separator = ' › ',
|
||||
): string {
|
||||
const byId = new Map(allMailboxes.map(m => [m.id, m]));
|
||||
const names: string[] = [mailbox.name];
|
||||
const visited = new Set<string>([mailbox.id]);
|
||||
let parentId = mailbox.parentId;
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
visited.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (!parent) break;
|
||||
names.unshift(parent.name);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return names.join(separator);
|
||||
}
|
||||
@@ -671,14 +671,24 @@
|
||||
"files": "Dateien",
|
||||
"contacts": "Kontakte",
|
||||
"sidebar_apps": "Sidebar-Apps",
|
||||
"notifications": "Benachrichtigungen"
|
||||
"notifications": "Benachrichtigungen",
|
||||
"layout": "Layout",
|
||||
"reading": "Lesen",
|
||||
"composing": "Verfassen",
|
||||
"content_senders": "Inhalte & Absender",
|
||||
"about_data": "Über & Daten",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Allgemein",
|
||||
"account": "Konto & Identität",
|
||||
"organization": "E-Mail-Organisation",
|
||||
"apps": "Apps",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"appearance": "Darstellung",
|
||||
"mail": "E-Mail",
|
||||
"privacy": "Datenschutz & Sicherheit",
|
||||
"advanced": "Erweitert"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Darstellung",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
|
||||
"empty_search": "Keine Kontakte gefunden",
|
||||
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
|
||||
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
|
||||
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
|
||||
"clear_search": "Suche löschen",
|
||||
"import_vcard": "vCard importieren",
|
||||
"delete_confirm_title": "Kontakt löschen",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"import_to_smime": "In S/MIME importieren",
|
||||
"cert_already_imported": "Zertifikat bereits importiert",
|
||||
"cert_imported": "Zertifikat importiert",
|
||||
"cert_import_failed": "Import des Zertifikats fehlgeschlagen"
|
||||
"cert_import_failed": "Import des Zertifikats fehlgeschlagen",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Weitere Aktionen",
|
||||
"age_years": "{count, plural, one {1 Jahr alt} other {# Jahre alt}}",
|
||||
"years_since": "{count, plural, one {1 Jahr} other {# Jahre}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Neuer Kontakt",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"email_error_inline": "Ungültiges E-Mail-Format",
|
||||
"save_failed": "Kontakt konnte nicht gespeichert werden",
|
||||
"delete": "Löschen"
|
||||
"delete": "Löschen",
|
||||
"upload_photo": "Foto hochladen",
|
||||
"remove_photo": "Foto entfernen",
|
||||
"photo_hint": "JPG oder PNG, bis zu 10 MB. Wird verkleinert.",
|
||||
"photo_too_large": "Bild ist zu groß (max. 10 MB)",
|
||||
"photo_invalid": "Ungültige Bilddatei",
|
||||
"change_photo": "Ändern"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Neue Gruppe",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Kontakt konnte nicht erstellt werden",
|
||||
"error_update": "Kontakt konnte nicht aktualisiert werden",
|
||||
"error_delete": "Kontakt konnte nicht gelöscht werden"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Öffnen",
|
||||
"edit": "Bearbeiten",
|
||||
"send_email": "E-Mail senden",
|
||||
"add_to_group": "Zur Gruppe hinzufügen",
|
||||
"export_vcard": "Als vCard exportieren",
|
||||
"delete": "Löschen",
|
||||
"call": "Anrufen",
|
||||
"duplicate": "Duplizieren",
|
||||
"print": "Drucken"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filter",
|
||||
"select": "Auswählen",
|
||||
"clear": "Zurücksetzen",
|
||||
"close": "Schließen",
|
||||
"title": "Erweiterte Filter",
|
||||
"organization": "Firma",
|
||||
"organization_placeholder": "z. B. Acme GmbH",
|
||||
"job_title": "Berufsbezeichnung",
|
||||
"job_title_placeholder": "z. B. Designer",
|
||||
"location": "Ort",
|
||||
"location_placeholder": "Stadt oder Land",
|
||||
"email_domain": "E-Mail-Domain",
|
||||
"email_domain_placeholder": "beispiel.de",
|
||||
"birthday_month": "Geburtstag im",
|
||||
"any_month": "Beliebiger Monat",
|
||||
"has_email": "Mit E-Mail",
|
||||
"has_phone": "Mit Telefon",
|
||||
"has_photo": "Mit Foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Vorläufig",
|
||||
"needs_action": "Antwort ausstehend",
|
||||
"remove": "Entfernen",
|
||||
"edit": "Bearbeiten",
|
||||
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
|
||||
"send_invitations": "Einladungen an Teilnehmer senden",
|
||||
"status_summary": "{accepted} zugesagt, {pending} ausstehend",
|
||||
|
||||
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "The organizer has cancelled this event.",
|
||||
"event_updated": "Update #{sequence}",
|
||||
"event_status_tentative": "Tentative",
|
||||
"event_status_cancelled": "Cancelled"
|
||||
"event_status_cancelled": "Cancelled",
|
||||
"expand": "Show details",
|
||||
"collapse": "Hide details"
|
||||
},
|
||||
"send": "Send",
|
||||
"more": "more"
|
||||
@@ -671,14 +673,24 @@
|
||||
"contacts": "Contacts",
|
||||
"encryption": "Encryption",
|
||||
"sidebar_apps": "Sidebar Apps",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"layout": "Layout",
|
||||
"reading": "Reading",
|
||||
"composing": "Composing",
|
||||
"content_senders": "Content & Senders",
|
||||
"about_data": "About & Data",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "General",
|
||||
"account": "Account & Identity",
|
||||
"organization": "Mail Organization",
|
||||
"apps": "Apps",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"appearance": "Appearance",
|
||||
"mail": "Mail",
|
||||
"privacy": "Privacy & Security",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -1310,6 +1322,8 @@
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
"group_by_letter_label": "Group by first letter",
|
||||
"group_by_letter_description": "Show alphabetical section headers in the contact list",
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
@@ -1737,6 +1751,8 @@
|
||||
"empty_state_subtitle": "Create your first contact or import from a vCard file",
|
||||
"empty_search": "No contacts match your search",
|
||||
"empty_search_hint": "Try a different search term",
|
||||
"empty_filtered": "No contacts match your filters",
|
||||
"empty_filtered_hint": "Try adjusting or clearing filters",
|
||||
"clear_search": "Clear search",
|
||||
"import_vcard": "Import vCard",
|
||||
"delete_confirm_title": "Delete contact",
|
||||
@@ -1814,7 +1830,22 @@
|
||||
"calendar": "Calendar",
|
||||
"calendar_uri": "Calendar URL",
|
||||
"scheduling_uri": "Scheduling URL",
|
||||
"freebusy_uri": "Free/Busy URL"
|
||||
"freebusy_uri": "Free/Busy URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "More actions",
|
||||
"age_years": "{count, plural, one {1 year old} other {# years old}}",
|
||||
"years_since": "{count, plural, one {1 year} other {# years}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1854,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "New Contact",
|
||||
@@ -1919,7 +1952,13 @@
|
||||
"email_invalid": "Please enter a valid email address",
|
||||
"email_error_inline": "Invalid email format",
|
||||
"save_failed": "Failed to save contact",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"upload_photo": "Upload photo",
|
||||
"remove_photo": "Remove photo",
|
||||
"photo_hint": "JPG or PNG, up to 10 MB. Will be resized.",
|
||||
"photo_too_large": "Image is too large (max 10 MB)",
|
||||
"photo_invalid": "Invalid image file",
|
||||
"change_photo": "Change"
|
||||
},
|
||||
"groups": {
|
||||
"create": "New Group",
|
||||
@@ -1986,7 +2025,30 @@
|
||||
"send_email": "Send email",
|
||||
"add_to_group": "Add to group",
|
||||
"export_vcard": "Export as vCard",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"call": "Call",
|
||||
"duplicate": "Duplicate",
|
||||
"print": "Print"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filters",
|
||||
"select": "Select",
|
||||
"clear": "Clear",
|
||||
"close": "Close",
|
||||
"title": "Advanced filters",
|
||||
"organization": "Company",
|
||||
"organization_placeholder": "e.g. Acme Corp",
|
||||
"job_title": "Job title",
|
||||
"job_title_placeholder": "e.g. Designer",
|
||||
"location": "Location",
|
||||
"location_placeholder": "City or country",
|
||||
"email_domain": "Email domain",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "Birthday in",
|
||||
"any_month": "Any month",
|
||||
"has_email": "Has email",
|
||||
"has_phone": "Has phone",
|
||||
"has_photo": "Has photo"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2062,6 +2124,7 @@
|
||||
"tentative": "Tentative",
|
||||
"needs_action": "Needs action",
|
||||
"remove": "Remove",
|
||||
"edit": "Edit",
|
||||
"email_placeholder": "Add email address or search contacts",
|
||||
"send_invitations": "Send invitations to participants",
|
||||
"status_summary": "{accepted} accepted, {pending} pending",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"files": "Archivos",
|
||||
"contacts": "Contactos",
|
||||
"sidebar_apps": "Apps de barra lateral",
|
||||
"notifications": "Notificaciones"
|
||||
"notifications": "Notificaciones",
|
||||
"layout": "Diseño",
|
||||
"reading": "Lectura",
|
||||
"composing": "Redacción",
|
||||
"content_senders": "Contenido y remitentes",
|
||||
"about_data": "Acerca de y datos",
|
||||
"debug": "Depuración"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "General",
|
||||
"account": "Cuenta e identidad",
|
||||
"organization": "Organización del correo",
|
||||
"apps": "Aplicaciones",
|
||||
"system": "Sistema"
|
||||
"system": "Sistema",
|
||||
"appearance": "Apariencia",
|
||||
"mail": "Correo",
|
||||
"privacy": "Privacidad y seguridad",
|
||||
"advanced": "Avanzado"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apariencia",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
|
||||
"empty_search": "Ningún contacto coincide con tu búsqueda",
|
||||
"empty_search_hint": "Prueba con otro término de búsqueda",
|
||||
"empty_filtered": "Ningún contacto coincide con tus filtros",
|
||||
"empty_filtered_hint": "Ajusta o limpia los filtros",
|
||||
"clear_search": "Borrar búsqueda",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Eliminar contacto",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"import_to_smime": "Importar a S/MIME",
|
||||
"cert_already_imported": "Certificado ya importado",
|
||||
"cert_imported": "Certificado importado",
|
||||
"cert_import_failed": "Error al importar el certificado"
|
||||
"cert_import_failed": "Error al importar el certificado",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Más acciones",
|
||||
"age_years": "{count, plural, one {1 año} other {# años}}",
|
||||
"years_since": "{count, plural, one {hace 1 año} other {hace # años}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuevo contacto",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Introduce una dirección de correo válida",
|
||||
"email_error_inline": "Formato de correo inválido",
|
||||
"save_failed": "Error al guardar el contacto",
|
||||
"delete": "Eliminar"
|
||||
"delete": "Eliminar",
|
||||
"upload_photo": "Subir foto",
|
||||
"remove_photo": "Quitar foto",
|
||||
"photo_hint": "JPG o PNG, hasta 10 MB. Se redimensionará.",
|
||||
"photo_too_large": "La imagen es demasiado grande (máx. 10 MB)",
|
||||
"photo_invalid": "Archivo de imagen no válido",
|
||||
"change_photo": "Cambiar"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuevo grupo",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Error al crear el contacto",
|
||||
"error_update": "Error al actualizar el contacto",
|
||||
"error_delete": "Error al eliminar el contacto"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Abrir",
|
||||
"edit": "Editar",
|
||||
"send_email": "Enviar correo",
|
||||
"add_to_group": "Añadir al grupo",
|
||||
"export_vcard": "Exportar como vCard",
|
||||
"delete": "Eliminar",
|
||||
"call": "Llamar",
|
||||
"duplicate": "Duplicar",
|
||||
"print": "Imprimir"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtros",
|
||||
"select": "Seleccionar",
|
||||
"clear": "Borrar",
|
||||
"close": "Cerrar",
|
||||
"title": "Filtros avanzados",
|
||||
"organization": "Empresa",
|
||||
"organization_placeholder": "p. ej. Acme",
|
||||
"job_title": "Puesto",
|
||||
"job_title_placeholder": "p. ej. Diseñador",
|
||||
"location": "Ubicación",
|
||||
"location_placeholder": "Ciudad o país",
|
||||
"email_domain": "Dominio de correo",
|
||||
"email_domain_placeholder": "ejemplo.com",
|
||||
"birthday_month": "Cumpleaños en",
|
||||
"any_month": "Cualquier mes",
|
||||
"has_email": "Con correo",
|
||||
"has_phone": "Con teléfono",
|
||||
"has_photo": "Con foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Provisional",
|
||||
"needs_action": "Pendiente de respuesta",
|
||||
"remove": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"email_placeholder": "Añadir dirección de correo o buscar contactos",
|
||||
"send_invitations": "Enviar invitaciones a los participantes",
|
||||
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"files": "Fichiers",
|
||||
"contacts": "Contacts",
|
||||
"sidebar_apps": "Apps de la barre latérale",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"layout": "Mise en page",
|
||||
"reading": "Lecture",
|
||||
"composing": "Rédaction",
|
||||
"content_senders": "Contenu et expéditeurs",
|
||||
"about_data": "À propos et données",
|
||||
"debug": "Débogage"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Général",
|
||||
"account": "Compte & Identité",
|
||||
"organization": "Organisation des e-mails",
|
||||
"apps": "Applications",
|
||||
"system": "Système"
|
||||
"system": "Système",
|
||||
"appearance": "Apparence",
|
||||
"mail": "Courrier",
|
||||
"privacy": "Confidentialité et sécurité",
|
||||
"advanced": "Avancé"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
|
||||
"empty_search": "Aucun contact ne correspond à votre recherche",
|
||||
"empty_search_hint": "Essayez un autre terme de recherche",
|
||||
"empty_filtered": "Aucun contact ne correspond à vos filtres",
|
||||
"empty_filtered_hint": "Ajustez ou effacez les filtres",
|
||||
"clear_search": "Effacer la recherche",
|
||||
"import_vcard": "Importer vCard",
|
||||
"delete_confirm_title": "Supprimer le contact",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"import_to_smime": "Importer dans S/MIME",
|
||||
"cert_already_imported": "Certificat déjà importé",
|
||||
"cert_imported": "Certificat importé",
|
||||
"cert_import_failed": "Échec de l'import du certificat"
|
||||
"cert_import_failed": "Échec de l'import du certificat",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Plus d'actions",
|
||||
"age_years": "{count, plural, one {1 an} other {# ans}}",
|
||||
"years_since": "{count, plural, one {il y a 1 an} other {il y a # ans}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nouveau contact",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Veuillez saisir une adresse e-mail valide",
|
||||
"email_error_inline": "Format d'e-mail invalide",
|
||||
"save_failed": "Échec de l'enregistrement du contact",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"upload_photo": "Téléverser une photo",
|
||||
"remove_photo": "Supprimer la photo",
|
||||
"photo_hint": "JPG ou PNG, jusqu’à 10 Mo. Sera redimensionnée.",
|
||||
"photo_too_large": "L’image est trop grande (max 10 Mo)",
|
||||
"photo_invalid": "Fichier image invalide",
|
||||
"change_photo": "Modifier"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nouveau groupe",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Échec de la création du contact",
|
||||
"error_update": "Échec de la mise à jour du contact",
|
||||
"error_delete": "Échec de la suppression du contact"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Ouvrir",
|
||||
"edit": "Modifier",
|
||||
"send_email": "Envoyer un courriel",
|
||||
"add_to_group": "Ajouter au groupe",
|
||||
"export_vcard": "Exporter en vCard",
|
||||
"delete": "Supprimer",
|
||||
"call": "Appeler",
|
||||
"duplicate": "Dupliquer",
|
||||
"print": "Imprimer"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtres",
|
||||
"select": "Sélectionner",
|
||||
"clear": "Effacer",
|
||||
"close": "Fermer",
|
||||
"title": "Filtres avancés",
|
||||
"organization": "Entreprise",
|
||||
"organization_placeholder": "ex. Acme",
|
||||
"job_title": "Poste",
|
||||
"job_title_placeholder": "ex. Designer",
|
||||
"location": "Lieu",
|
||||
"location_placeholder": "Ville ou pays",
|
||||
"email_domain": "Domaine e-mail",
|
||||
"email_domain_placeholder": "exemple.com",
|
||||
"birthday_month": "Anniversaire en",
|
||||
"any_month": "Tous les mois",
|
||||
"has_email": "Avec e-mail",
|
||||
"has_phone": "Avec téléphone",
|
||||
"has_photo": "Avec photo"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Provisoire",
|
||||
"needs_action": "En attente de réponse",
|
||||
"remove": "Retirer",
|
||||
"edit": "Modifier",
|
||||
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
|
||||
"send_invitations": "Envoyer les invitations aux participants",
|
||||
"status_summary": "{accepted} accepté(s), {pending} en attente",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"files": "File",
|
||||
"contacts": "Contatti",
|
||||
"sidebar_apps": "App nella barra laterale",
|
||||
"notifications": "Notifiche"
|
||||
"notifications": "Notifiche",
|
||||
"layout": "Layout",
|
||||
"reading": "Lettura",
|
||||
"composing": "Composizione",
|
||||
"content_senders": "Contenuto e mittenti",
|
||||
"about_data": "Informazioni e dati",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Generale",
|
||||
"account": "Account e identità",
|
||||
"organization": "Organizzazione e-mail",
|
||||
"apps": "Applicazioni",
|
||||
"system": "Sistema"
|
||||
"system": "Sistema",
|
||||
"appearance": "Aspetto",
|
||||
"mail": "Posta",
|
||||
"privacy": "Privacy e sicurezza",
|
||||
"advanced": "Avanzate"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aspetto",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
|
||||
"empty_search": "Nessun contatto corrisponde alla ricerca",
|
||||
"empty_search_hint": "Prova con un altro termine di ricerca",
|
||||
"empty_filtered": "Nessun contatto corrisponde ai filtri",
|
||||
"empty_filtered_hint": "Modifica o cancella i filtri",
|
||||
"clear_search": "Cancella ricerca",
|
||||
"import_vcard": "Importa vCard",
|
||||
"delete_confirm_title": "Elimina contatto",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"import_to_smime": "Importa in S/MIME",
|
||||
"cert_already_imported": "Certificato già importato",
|
||||
"cert_imported": "Certificato importato",
|
||||
"cert_import_failed": "Importazione del certificato non riuscita"
|
||||
"cert_import_failed": "Importazione del certificato non riuscita",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Altre azioni",
|
||||
"age_years": "{count, plural, one {1 anno} other {# anni}}",
|
||||
"years_since": "{count, plural, one {1 anno fa} other {# anni fa}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuovo contatto",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Inserisci un indirizzo email valido",
|
||||
"email_error_inline": "Formato email non valido",
|
||||
"save_failed": "Impossibile salvare il contatto",
|
||||
"delete": "Elimina"
|
||||
"delete": "Elimina",
|
||||
"upload_photo": "Carica foto",
|
||||
"remove_photo": "Rimuovi foto",
|
||||
"photo_hint": "JPG o PNG, fino a 10 MB. Verrà ridimensionata.",
|
||||
"photo_too_large": "L'immagine è troppo grande (max 10 MB)",
|
||||
"photo_invalid": "File immagine non valido",
|
||||
"change_photo": "Cambia"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuovo gruppo",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Impossibile creare il contatto",
|
||||
"error_update": "Impossibile aggiornare il contatto",
|
||||
"error_delete": "Impossibile eliminare il contatto"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Apri",
|
||||
"edit": "Modifica",
|
||||
"send_email": "Invia email",
|
||||
"add_to_group": "Aggiungi al gruppo",
|
||||
"export_vcard": "Esporta come vCard",
|
||||
"delete": "Elimina",
|
||||
"call": "Chiama",
|
||||
"duplicate": "Duplica",
|
||||
"print": "Stampa"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtri",
|
||||
"select": "Seleziona",
|
||||
"clear": "Azzera",
|
||||
"close": "Chiudi",
|
||||
"title": "Filtri avanzati",
|
||||
"organization": "Azienda",
|
||||
"organization_placeholder": "es. Acme",
|
||||
"job_title": "Ruolo",
|
||||
"job_title_placeholder": "es. Designer",
|
||||
"location": "Luogo",
|
||||
"location_placeholder": "Città o paese",
|
||||
"email_domain": "Dominio email",
|
||||
"email_domain_placeholder": "esempio.com",
|
||||
"birthday_month": "Compleanno in",
|
||||
"any_month": "Qualsiasi mese",
|
||||
"has_email": "Con email",
|
||||
"has_phone": "Con telefono",
|
||||
"has_photo": "Con foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Provvisorio",
|
||||
"needs_action": "In attesa di risposta",
|
||||
"remove": "Rimuovi",
|
||||
"edit": "Modifica",
|
||||
"email_placeholder": "Aggiungi indirizzo email o cerca contatti",
|
||||
"send_invitations": "Invia inviti ai partecipanti",
|
||||
"status_summary": "{accepted} accettato/i, {pending} in attesa",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"files": "ファイル",
|
||||
"contacts": "連絡先",
|
||||
"sidebar_apps": "サイドバーアプリ",
|
||||
"notifications": "通知"
|
||||
"notifications": "通知",
|
||||
"layout": "レイアウト",
|
||||
"reading": "閲覧",
|
||||
"composing": "作成",
|
||||
"content_senders": "コンテンツと送信者",
|
||||
"about_data": "情報とデータ",
|
||||
"debug": "デバッグ"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "一般",
|
||||
"account": "アカウントと身元",
|
||||
"organization": "メール整理",
|
||||
"apps": "アプリ",
|
||||
"system": "システム"
|
||||
"system": "システム",
|
||||
"appearance": "外観",
|
||||
"mail": "メール",
|
||||
"privacy": "プライバシーとセキュリティ",
|
||||
"advanced": "詳細"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外観",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
|
||||
"empty_search": "検索に一致する連絡先がありません",
|
||||
"empty_search_hint": "別の検索語をお試しください",
|
||||
"empty_filtered": "フィルターに一致する連絡先がありません",
|
||||
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
|
||||
"clear_search": "検索をクリア",
|
||||
"import_vcard": "vCardをインポート",
|
||||
"delete_confirm_title": "連絡先を削除",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"import_to_smime": "S/MIME にインポート",
|
||||
"cert_already_imported": "証明書はすでにインポートされています",
|
||||
"cert_imported": "証明書をインポートしました",
|
||||
"cert_import_failed": "証明書のインポートに失敗しました"
|
||||
"cert_import_failed": "証明書のインポートに失敗しました",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "その他の操作",
|
||||
"age_years": "{count}歳",
|
||||
"years_since": "{count}年前"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新しい連絡先",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "有効なメールアドレスを入力してください",
|
||||
"email_error_inline": "メールアドレスの形式が正しくありません",
|
||||
"save_failed": "連絡先の保存に失敗しました",
|
||||
"delete": "削除"
|
||||
"delete": "削除",
|
||||
"upload_photo": "写真をアップロード",
|
||||
"remove_photo": "写真を削除",
|
||||
"photo_hint": "JPGまたはPNG、最大10MB。リサイズされます。",
|
||||
"photo_too_large": "画像が大きすぎます (最大10MB)",
|
||||
"photo_invalid": "無効な画像ファイル",
|
||||
"change_photo": "変更"
|
||||
},
|
||||
"groups": {
|
||||
"create": "新しいグループ",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "連絡先の作成に失敗しました",
|
||||
"error_update": "連絡先の更新に失敗しました",
|
||||
"error_delete": "連絡先の削除に失敗しました"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "開く",
|
||||
"edit": "編集",
|
||||
"send_email": "メール送信",
|
||||
"add_to_group": "グループに追加",
|
||||
"export_vcard": "vCardとしてエクスポート",
|
||||
"delete": "削除",
|
||||
"call": "電話する",
|
||||
"duplicate": "複製",
|
||||
"print": "印刷"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "フィルター",
|
||||
"select": "選択",
|
||||
"clear": "クリア",
|
||||
"close": "閉じる",
|
||||
"title": "詳細フィルター",
|
||||
"organization": "会社",
|
||||
"organization_placeholder": "例: Acme",
|
||||
"job_title": "役職",
|
||||
"job_title_placeholder": "例: デザイナー",
|
||||
"location": "場所",
|
||||
"location_placeholder": "都市または国",
|
||||
"email_domain": "メールドメイン",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "誕生月",
|
||||
"any_month": "すべての月",
|
||||
"has_email": "メールあり",
|
||||
"has_phone": "電話あり",
|
||||
"has_photo": "写真あり"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "仮承諾",
|
||||
"needs_action": "未回答",
|
||||
"remove": "削除",
|
||||
"edit": "編集",
|
||||
"email_placeholder": "メールアドレスを追加または連絡先を検索",
|
||||
"send_invitations": "参加者に招待を送信",
|
||||
"status_summary": "{accepted}人承諾、{pending}人保留",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"contacts": "연락처",
|
||||
"encryption": "암호화",
|
||||
"sidebar_apps": "사이드바 앱",
|
||||
"notifications": "알림"
|
||||
"notifications": "알림",
|
||||
"layout": "레이아웃",
|
||||
"reading": "읽기",
|
||||
"composing": "작성",
|
||||
"content_senders": "콘텐츠 및 발신자",
|
||||
"about_data": "정보 및 데이터",
|
||||
"debug": "디버그"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "일반",
|
||||
"account": "계정 및 인증",
|
||||
"organization": "메일 정리",
|
||||
"apps": "앱",
|
||||
"system": "시스템"
|
||||
"system": "시스템",
|
||||
"appearance": "모양",
|
||||
"mail": "메일",
|
||||
"privacy": "개인정보 및 보안",
|
||||
"advanced": "고급"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "화면 설정",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
|
||||
"empty_search": "검색된 연락처가 없어요",
|
||||
"empty_search_hint": "다른 검색어로 다시 시도해 보세요",
|
||||
"empty_filtered": "필터에 일치하는 연락처가 없어요",
|
||||
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
|
||||
"clear_search": "검색 지우기",
|
||||
"import_vcard": "vCard 가져오기",
|
||||
"delete_confirm_title": "연락처 삭제",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"calendar": "캘린더",
|
||||
"calendar_uri": "캘린더 URL",
|
||||
"scheduling_uri": "스케줄링 URL",
|
||||
"freebusy_uri": "Free/Busy URL"
|
||||
"freebusy_uri": "Free/Busy URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "작업 더보기",
|
||||
"age_years": "{count}세",
|
||||
"years_since": "{count}년 전"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "새 연락처",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
|
||||
"email_error_inline": "이메일 형식이 잘못되었어요",
|
||||
"save_failed": "연락처를 저장하지 못했어요",
|
||||
"delete": "삭제"
|
||||
"delete": "삭제",
|
||||
"upload_photo": "사진 업로드",
|
||||
"remove_photo": "사진 제거",
|
||||
"photo_hint": "JPG 또는 PNG, 최도 10MB. 크기가 조정됩니다.",
|
||||
"photo_too_large": "이미지가 너무 큽니다 (최대 10MB)",
|
||||
"photo_invalid": "잘못된 이미지 파일",
|
||||
"change_photo": "변경"
|
||||
},
|
||||
"groups": {
|
||||
"create": "새 그룹",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "연락처를 만들지 못했어요",
|
||||
"error_update": "연락처를 업데이트하지 못했어요",
|
||||
"error_delete": "연락처를 삭제하지 못했어요"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "열기",
|
||||
"edit": "편집",
|
||||
"send_email": "이메일 보내기",
|
||||
"add_to_group": "그룹에 추가",
|
||||
"export_vcard": "vCard로 내보내기",
|
||||
"delete": "삭제",
|
||||
"call": "통화",
|
||||
"duplicate": "복제",
|
||||
"print": "인쇄"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "필터",
|
||||
"select": "선택",
|
||||
"clear": "지우기",
|
||||
"close": "닫기",
|
||||
"title": "고급 필터",
|
||||
"organization": "회사",
|
||||
"organization_placeholder": "예: Acme",
|
||||
"job_title": "직책",
|
||||
"job_title_placeholder": "예: 디자이너",
|
||||
"location": "위치",
|
||||
"location_placeholder": "도시 또는 국가",
|
||||
"email_domain": "이메일 도메인",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "생일 월",
|
||||
"any_month": "모든 달",
|
||||
"has_email": "이메일 있음",
|
||||
"has_phone": "전화번호 있음",
|
||||
"has_photo": "사진 있음"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "미정",
|
||||
"needs_action": "응답 필요",
|
||||
"remove": "삭제",
|
||||
"edit": "편집",
|
||||
"email_placeholder": "이메일을 추가하거나 연락처를 검색하세요",
|
||||
"send_invitations": "참석자에게 초대장 보내기",
|
||||
"status_summary": "{accepted}명 수락, {pending}명 대기 중",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"contacts": "Kontakti",
|
||||
"encryption": "Šifrēšana",
|
||||
"sidebar_apps": "Sānu joslas lietotnes",
|
||||
"notifications": "Paziņojumi"
|
||||
"notifications": "Paziņojumi",
|
||||
"layout": "Izkārtojums",
|
||||
"reading": "Lasīšana",
|
||||
"composing": "Rakstīšana",
|
||||
"content_senders": "Saturs un sūtītāji",
|
||||
"about_data": "Par un dati",
|
||||
"debug": "Atkļūdošana"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Vispārīgi",
|
||||
"account": "Konts un identitāte",
|
||||
"organization": "Pasta organizēšana",
|
||||
"apps": "Lietotnes",
|
||||
"system": "Sistēma"
|
||||
"system": "Sistēma",
|
||||
"appearance": "Izskats",
|
||||
"mail": "Pasts",
|
||||
"privacy": "Privātums un drošība",
|
||||
"advanced": "Papildu"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Izskats",
|
||||
@@ -1733,6 +1743,8 @@
|
||||
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
|
||||
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
|
||||
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
|
||||
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
|
||||
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
|
||||
"clear_search": "Notīrīt meklēšanu",
|
||||
"import_vcard": "vCard imports",
|
||||
"delete_confirm_title": "Dzēst kontaktu",
|
||||
@@ -1810,7 +1822,22 @@
|
||||
"calendar": "Kalendārs",
|
||||
"calendar_uri": "Kalendāra URL",
|
||||
"scheduling_uri": "Plānošanas URL",
|
||||
"freebusy_uri": "Pieejamības URL"
|
||||
"freebusy_uri": "Pieejamības URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Citas darbības",
|
||||
"age_years": "{count, plural, one {1 gads} other {# gadi}}",
|
||||
"years_since": "{count, plural, one {pirms 1 gada} other {pirms # gadiem}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1819,7 +1846,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Jauns kontakts",
|
||||
@@ -1915,7 +1944,13 @@
|
||||
"email_invalid": "Ievadiet derīgu e-pasta adresi",
|
||||
"email_error_inline": "Nederīgs e-pasta formāts",
|
||||
"save_failed": "Neizdevās saglabāt kontaktu",
|
||||
"delete": "Dzēst"
|
||||
"delete": "Dzēst",
|
||||
"upload_photo": "Augšupielādēt foto",
|
||||
"remove_photo": "Noņemt foto",
|
||||
"photo_hint": "JPG vai PNG, līdz 10 MB. Tiks mainīts izmērs.",
|
||||
"photo_too_large": "Attēls ir pārāk liels (maks. 10 MB)",
|
||||
"photo_invalid": "Nederīgs attēla fails",
|
||||
"change_photo": "Mainīt"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Jauna grupa",
|
||||
@@ -1979,7 +2014,38 @@
|
||||
"rename_category": "Pārdēvēt kategoriju",
|
||||
"category_name_label": "Kategorijas nosaukums",
|
||||
"category_renamed": "Kategorija pārdēvēta",
|
||||
"category_rename_failed": "Neizdevās pārdēvēt kategoriju"
|
||||
"category_rename_failed": "Neizdevās pārdēvēt kategoriju",
|
||||
"context_menu": {
|
||||
"open": "Atvērt",
|
||||
"edit": "Rediģēt",
|
||||
"send_email": "Sūtīt e-pastu",
|
||||
"add_to_group": "Pievienot grupai",
|
||||
"export_vcard": "Eksportēt kā vCard",
|
||||
"delete": "Dzēst",
|
||||
"call": "Zvanīt",
|
||||
"duplicate": "Dublēt",
|
||||
"print": "Drukāt"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtri",
|
||||
"select": "Atlasīt",
|
||||
"clear": "Notīrīt",
|
||||
"close": "Aizvērt",
|
||||
"title": "Paplašinātie filtri",
|
||||
"organization": "Uzņēmums",
|
||||
"organization_placeholder": "piem. Acme",
|
||||
"job_title": "Amats",
|
||||
"job_title_placeholder": "piem. Dizainers",
|
||||
"location": "Atrašanās vieta",
|
||||
"location_placeholder": "Pilsēta vai valsts",
|
||||
"email_domain": "E-pasta domēns",
|
||||
"email_domain_placeholder": "piemers.lv",
|
||||
"birthday_month": "Dzimšanas diena",
|
||||
"any_month": "Jebkurš mēnesis",
|
||||
"has_email": "Ar e-pastu",
|
||||
"has_phone": "Ar tālruni",
|
||||
"has_photo": "Ar foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendārs",
|
||||
@@ -2053,6 +2119,7 @@
|
||||
"tentative": "Pagaidām",
|
||||
"needs_action": "Nepieciešama atbilde",
|
||||
"remove": "Noņemt",
|
||||
"edit": "Rediģēt",
|
||||
"email_placeholder": "Pievienojiet adresi vai kontaktu",
|
||||
"send_invitations": "Sūtīt uzaicinājumus dalībniekiem",
|
||||
"status_summary": "{accepted} pieņēmuši, {pending} gaida",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"files": "Bestanden",
|
||||
"contacts": "Contacten",
|
||||
"sidebar_apps": "Zijbalk-apps",
|
||||
"notifications": "Meldingen"
|
||||
"notifications": "Meldingen",
|
||||
"layout": "Indeling",
|
||||
"reading": "Lezen",
|
||||
"composing": "Opstellen",
|
||||
"content_senders": "Inhoud en afzenders",
|
||||
"about_data": "Over en gegevens",
|
||||
"debug": "Debuggen"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Algemeen",
|
||||
"account": "Account & identiteit",
|
||||
"organization": "E-mailorganisatie",
|
||||
"apps": "Apps",
|
||||
"system": "Systeem"
|
||||
"system": "Systeem",
|
||||
"appearance": "Weergave",
|
||||
"mail": "E-mail",
|
||||
"privacy": "Privacy en beveiliging",
|
||||
"advanced": "Geavanceerd"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Uiterlijk",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
|
||||
"empty_search": "Geen contacten gevonden",
|
||||
"empty_search_hint": "Probeer een andere zoekterm",
|
||||
"empty_filtered": "Geen contacten voldoen aan uw filters",
|
||||
"empty_filtered_hint": "Pas filters aan of wis ze",
|
||||
"clear_search": "Zoekopdracht wissen",
|
||||
"import_vcard": "vCard importeren",
|
||||
"delete_confirm_title": "Contact verwijderen",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"import_to_smime": "Importeren naar S/MIME",
|
||||
"cert_already_imported": "Certificaat is al geïmporteerd",
|
||||
"cert_imported": "Certificaat geïmporteerd",
|
||||
"cert_import_failed": "Importeren van certificaat mislukt"
|
||||
"cert_import_failed": "Importeren van certificaat mislukt",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Meer acties",
|
||||
"age_years": "{count, plural, one {1 jaar} other {# jaar}}",
|
||||
"years_since": "{count, plural, one {1 jaar geleden} other {# jaar geleden}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nieuw contact",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Voer een geldig e-mailadres in",
|
||||
"email_error_inline": "Ongeldig e-mailformaat",
|
||||
"save_failed": "Kon contact niet opslaan",
|
||||
"delete": "Verwijderen"
|
||||
"delete": "Verwijderen",
|
||||
"upload_photo": "Foto uploaden",
|
||||
"remove_photo": "Foto verwijderen",
|
||||
"photo_hint": "JPG of PNG, tot 10 MB. Wordt verkleind.",
|
||||
"photo_too_large": "Afbeelding is te groot (max 10 MB)",
|
||||
"photo_invalid": "Ongeldig afbeeldingsbestand",
|
||||
"change_photo": "Wijzigen"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nieuwe groep",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Kon contact niet aanmaken",
|
||||
"error_update": "Kon contact niet bijwerken",
|
||||
"error_delete": "Kon contact niet verwijderen"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Openen",
|
||||
"edit": "Bewerken",
|
||||
"send_email": "E-mail verzenden",
|
||||
"add_to_group": "Aan groep toevoegen",
|
||||
"export_vcard": "Exporteren als vCard",
|
||||
"delete": "Verwijderen",
|
||||
"call": "Bellen",
|
||||
"duplicate": "Dupliceren",
|
||||
"print": "Afdrukken"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filters",
|
||||
"select": "Selecteren",
|
||||
"clear": "Wissen",
|
||||
"close": "Sluiten",
|
||||
"title": "Geavanceerde filters",
|
||||
"organization": "Bedrijf",
|
||||
"organization_placeholder": "bijv. Acme",
|
||||
"job_title": "Functie",
|
||||
"job_title_placeholder": "bijv. Ontwerper",
|
||||
"location": "Locatie",
|
||||
"location_placeholder": "Stad of land",
|
||||
"email_domain": "E-maildomein",
|
||||
"email_domain_placeholder": "voorbeeld.nl",
|
||||
"birthday_month": "Verjaardag in",
|
||||
"any_month": "Elke maand",
|
||||
"has_email": "Met e-mail",
|
||||
"has_phone": "Met telefoon",
|
||||
"has_photo": "Met foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Voorlopig",
|
||||
"needs_action": "Reactie vereist",
|
||||
"remove": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"email_placeholder": "E-mailadres toevoegen of contacten zoeken",
|
||||
"send_invitations": "Uitnodigingen sturen naar deelnemers",
|
||||
"status_summary": "{accepted} geaccepteerd, {pending} in afwachting",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"contacts": "Kontakty",
|
||||
"encryption": "Szyfrowanie",
|
||||
"sidebar_apps": "Aplikacje paska bocznego",
|
||||
"notifications": "Powiadomienia"
|
||||
"notifications": "Powiadomienia",
|
||||
"layout": "Układ",
|
||||
"reading": "Czytanie",
|
||||
"composing": "Tworzenie",
|
||||
"content_senders": "Treść i nadawcy",
|
||||
"about_data": "O programie i dane",
|
||||
"debug": "Debugowanie"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Ogólne",
|
||||
"account": "Konto i tożsamość",
|
||||
"organization": "Organizacja poczty",
|
||||
"apps": "Aplikacje",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"appearance": "Wygląd",
|
||||
"mail": "Poczta",
|
||||
"privacy": "Prywatność i bezpieczeństwo",
|
||||
"advanced": "Zaawansowane"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Wygląd",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
|
||||
"empty_search": "Żadne kontakty nie pasują do wyszukiwania",
|
||||
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
|
||||
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
|
||||
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
|
||||
"clear_search": "Wyczyść wyszukiwanie",
|
||||
"import_vcard": "Importuj vCard",
|
||||
"delete_confirm_title": "Usuń kontakt",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"calendar": "Kalendarz",
|
||||
"calendar_uri": "Adres URL kalendarza",
|
||||
"scheduling_uri": "Adres URL planowania",
|
||||
"freebusy_uri": "Adres URL wolny/zajęty"
|
||||
"freebusy_uri": "Adres URL wolny/zajęty",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Więcej działań",
|
||||
"age_years": "{count, plural, one {1 rok} few {# lata} many {# lat} other {# lat}}",
|
||||
"years_since": "{count, plural, one {1 rok temu} few {# lata temu} many {# lat temu} other {# lat temu}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nowy kontakt",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Wprowadź prawidłowy adres e-mail",
|
||||
"email_error_inline": "Nieprawidłowy format adresu e-mail",
|
||||
"save_failed": "Nie udało się zapisać kontaktu",
|
||||
"delete": "Usuń"
|
||||
"delete": "Usuń",
|
||||
"upload_photo": "Prześlij zdjęcie",
|
||||
"remove_photo": "Usuń zdjęcie",
|
||||
"photo_hint": "JPG lub PNG, do 10 MB. Zostanie zmniejszone.",
|
||||
"photo_too_large": "Obraz jest za duży (maks. 10 MB)",
|
||||
"photo_invalid": "Nieprawidłowy plik obrazu",
|
||||
"change_photo": "Zmień"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nowa grupa",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Nie udało się utworzyć kontaktu",
|
||||
"error_update": "Nie udało się zaktualizować kontaktu",
|
||||
"error_delete": "Nie udało się usunąć kontaktu"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Otwórz",
|
||||
"edit": "Edytuj",
|
||||
"send_email": "Wyślij e-mail",
|
||||
"add_to_group": "Dodaj do grupy",
|
||||
"export_vcard": "Eksportuj jako vCard",
|
||||
"delete": "Usuń",
|
||||
"call": "Zadzwoń",
|
||||
"duplicate": "Duplikuj",
|
||||
"print": "Drukuj"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtry",
|
||||
"select": "Zaznacz",
|
||||
"clear": "Wyczyść",
|
||||
"close": "Zamknij",
|
||||
"title": "Filtry zaawansowane",
|
||||
"organization": "Firma",
|
||||
"organization_placeholder": "np. Acme",
|
||||
"job_title": "Stanowisko",
|
||||
"job_title_placeholder": "np. Projektant",
|
||||
"location": "Lokalizacja",
|
||||
"location_placeholder": "Miasto lub kraj",
|
||||
"email_domain": "Domena e-mail",
|
||||
"email_domain_placeholder": "przyklad.pl",
|
||||
"birthday_month": "Urodziny w",
|
||||
"any_month": "Dowolny miesiąc",
|
||||
"has_email": "Z e-mailem",
|
||||
"has_phone": "Z telefonem",
|
||||
"has_photo": "Ze zdjęciem"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Wstępnie",
|
||||
"needs_action": "Wymaga działania",
|
||||
"remove": "Usuń",
|
||||
"edit": "Edytuj",
|
||||
"email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty",
|
||||
"send_invitations": "Wyślij zaproszenia do uczestników",
|
||||
"status_summary": "{accepted} zaakceptowano, {pending} oczekuje",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"files": "Arquivos",
|
||||
"contacts": "Contactos",
|
||||
"sidebar_apps": "Apps da barra lateral",
|
||||
"notifications": "Notificações"
|
||||
"notifications": "Notificações",
|
||||
"layout": "Layout",
|
||||
"reading": "Leitura",
|
||||
"composing": "Composição",
|
||||
"content_senders": "Conteúdo e remetentes",
|
||||
"about_data": "Sobre e dados",
|
||||
"debug": "Depuração"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Geral",
|
||||
"account": "Conta e identidade",
|
||||
"organization": "Organização de e-mail",
|
||||
"apps": "Aplicativos",
|
||||
"system": "Sistema"
|
||||
"system": "Sistema",
|
||||
"appearance": "Aparência",
|
||||
"mail": "Correio",
|
||||
"privacy": "Privacidade e segurança",
|
||||
"advanced": "Avançado"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aparência",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
|
||||
"empty_search": "Nenhum contato encontrado",
|
||||
"empty_search_hint": "Tente outro termo de pesquisa",
|
||||
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
|
||||
"empty_filtered_hint": "Ajuste ou limpe os filtros",
|
||||
"clear_search": "Limpar pesquisa",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Excluir contato",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"import_to_smime": "Importar para S/MIME",
|
||||
"cert_already_imported": "Certificado já importado",
|
||||
"cert_imported": "Certificado importado",
|
||||
"cert_import_failed": "Falha ao importar o certificado"
|
||||
"cert_import_failed": "Falha ao importar o certificado",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Mais ações",
|
||||
"age_years": "{count, plural, one {1 ano} other {# anos}}",
|
||||
"years_since": "{count, plural, one {há 1 ano} other {há # anos}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Novo contato",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Por favor, insira um endereço de e-mail válido",
|
||||
"email_error_inline": "Formato de e-mail inválido",
|
||||
"save_failed": "Falha ao salvar contato",
|
||||
"delete": "Excluir"
|
||||
"delete": "Excluir",
|
||||
"upload_photo": "Carregar foto",
|
||||
"remove_photo": "Remover foto",
|
||||
"photo_hint": "JPG ou PNG, até 10 MB. Será redimensionada.",
|
||||
"photo_too_large": "A imagem é muito grande (máx. 10 MB)",
|
||||
"photo_invalid": "Arquivo de imagem inválido",
|
||||
"change_photo": "Alterar"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Novo grupo",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Falha ao criar contato",
|
||||
"error_update": "Falha ao atualizar contato",
|
||||
"error_delete": "Falha ao excluir contato"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Abrir",
|
||||
"edit": "Editar",
|
||||
"send_email": "Enviar e-mail",
|
||||
"add_to_group": "Adicionar ao grupo",
|
||||
"export_vcard": "Exportar como vCard",
|
||||
"delete": "Excluir",
|
||||
"call": "Ligar",
|
||||
"duplicate": "Duplicar",
|
||||
"print": "Imprimir"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtros",
|
||||
"select": "Selecionar",
|
||||
"clear": "Limpar",
|
||||
"close": "Fechar",
|
||||
"title": "Filtros avançados",
|
||||
"organization": "Empresa",
|
||||
"organization_placeholder": "ex. Acme",
|
||||
"job_title": "Cargo",
|
||||
"job_title_placeholder": "ex. Designer",
|
||||
"location": "Localização",
|
||||
"location_placeholder": "Cidade ou país",
|
||||
"email_domain": "Domínio de e-mail",
|
||||
"email_domain_placeholder": "exemplo.com",
|
||||
"birthday_month": "Aniversário em",
|
||||
"any_month": "Qualquer mês",
|
||||
"has_email": "Com e-mail",
|
||||
"has_phone": "Com telefone",
|
||||
"has_photo": "Com foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Provisório",
|
||||
"needs_action": "Aguardando resposta",
|
||||
"remove": "Remover",
|
||||
"edit": "Editar",
|
||||
"email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos",
|
||||
"send_invitations": "Enviar convites aos participantes",
|
||||
"status_summary": "{accepted} aceito(s), {pending} pendente(s)",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"contacts": "Контакты",
|
||||
"encryption": "Шифрование",
|
||||
"sidebar_apps": "Приложения боковой панели",
|
||||
"notifications": "Уведомления"
|
||||
"notifications": "Уведомления",
|
||||
"layout": "Макет",
|
||||
"reading": "Чтение",
|
||||
"composing": "Написание",
|
||||
"content_senders": "Содержимое и отправители",
|
||||
"about_data": "О программе и данные",
|
||||
"debug": "Отладка"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Общие",
|
||||
"account": "Аккаунт и удостоверение",
|
||||
"organization": "Организация почты",
|
||||
"apps": "Приложения",
|
||||
"system": "Система"
|
||||
"system": "Система",
|
||||
"appearance": "Внешний вид",
|
||||
"mail": "Почта",
|
||||
"privacy": "Конфиденциальность и безопасность",
|
||||
"advanced": "Дополнительно"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Внешний вид",
|
||||
@@ -1117,7 +1127,7 @@
|
||||
"description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
|
||||
"name_label": "Название ключа",
|
||||
"name_placeholder": "напр. Скрипт резервного копирования, CI runner",
|
||||
"copy_now_warning": "Скопируйте этот API-ключ сейчас — он больше не будет показан.",
|
||||
"copy_now_warning": "Скопируйте этот API-ключ сейчас – он больше не будет показан.",
|
||||
"added": "API-ключ создан",
|
||||
"removed": "API-ключ удалён",
|
||||
"add_error": "Не удалось создать API-ключ",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
|
||||
"empty_search": "Нет контактов, соответствующих вашему запросу",
|
||||
"empty_search_hint": "Попробуйте другой поисковый запрос",
|
||||
"empty_filtered": "Нет контактов, соответствующих фильтрам",
|
||||
"empty_filtered_hint": "Измените или очистите фильтры",
|
||||
"clear_search": "Очистить поиск",
|
||||
"import_vcard": "Импорт vCard",
|
||||
"delete_confirm_title": "Удалить контакт",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"calendar": "Календарь",
|
||||
"calendar_uri": "URL календаря",
|
||||
"scheduling_uri": "URL планирования",
|
||||
"freebusy_uri": "URL доступности"
|
||||
"freebusy_uri": "URL доступности",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Другие действия",
|
||||
"age_years": "{count, plural, one {# год} few {# года} many {# лет} other {# лет}}",
|
||||
"years_since": "{count, plural, one {# год назад} few {# года назад} many {# лет назад} other {# лет назад}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Новый контакт",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Введите корректный адрес электронной почты",
|
||||
"email_error_inline": "Неверный формат email",
|
||||
"save_failed": "Не удалось сохранить контакт",
|
||||
"delete": "Удалить"
|
||||
"delete": "Удалить",
|
||||
"upload_photo": "Загрузить фото",
|
||||
"remove_photo": "Удалить фото",
|
||||
"photo_hint": "JPG или PNG, до 10 МБ. Будет уменьшено.",
|
||||
"photo_too_large": "Изображение слишком большое (макс. 10 МБ)",
|
||||
"photo_invalid": "Недопустимый файл изображения",
|
||||
"change_photo": "Изменить"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Новая группа",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Не удалось создать контакт",
|
||||
"error_update": "Не удалось обновить контакт",
|
||||
"error_delete": "Не удалось удалить контакт"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Открыть",
|
||||
"edit": "Изменить",
|
||||
"send_email": "Отправить письмо",
|
||||
"add_to_group": "Добавить в группу",
|
||||
"export_vcard": "Экспортировать как vCard",
|
||||
"delete": "Удалить",
|
||||
"call": "Позвонить",
|
||||
"duplicate": "Дублировать",
|
||||
"print": "Печать"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Фильтры",
|
||||
"select": "Выбрать",
|
||||
"clear": "Очистить",
|
||||
"close": "Закрыть",
|
||||
"title": "Расширенные фильтры",
|
||||
"organization": "Компания",
|
||||
"organization_placeholder": "напр. Acme",
|
||||
"job_title": "Должность",
|
||||
"job_title_placeholder": "напр. Дизайнер",
|
||||
"location": "Местоположение",
|
||||
"location_placeholder": "Город или страна",
|
||||
"email_domain": "Домен e-mail",
|
||||
"email_domain_placeholder": "primer.ru",
|
||||
"birthday_month": "День рождения в",
|
||||
"any_month": "Любой месяц",
|
||||
"has_email": "С эл. почтой",
|
||||
"has_phone": "С телефоном",
|
||||
"has_photo": "С фото"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Предварительно",
|
||||
"needs_action": "Требует ответа",
|
||||
"remove": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"email_placeholder": "Добавьте адрес или найдите контакт",
|
||||
"send_invitations": "Отправить приглашения участникам",
|
||||
"status_summary": "{accepted} принято, {pending} ожидает",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"contacts": "Контакти",
|
||||
"encryption": "Шифрування",
|
||||
"sidebar_apps": "Програми бічної панелі",
|
||||
"notifications": "Сповіщення"
|
||||
"notifications": "Сповіщення",
|
||||
"layout": "Макет",
|
||||
"reading": "Читання",
|
||||
"composing": "Написання",
|
||||
"content_senders": "Вміст і відправники",
|
||||
"about_data": "Про програму та дані",
|
||||
"debug": "Налагодження"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Загальний",
|
||||
"account": "Обліковий запис і ідентифікатор",
|
||||
"organization": "Організація пошти",
|
||||
"apps": "програми",
|
||||
"system": "система"
|
||||
"system": "система",
|
||||
"appearance": "Вигляд",
|
||||
"mail": "Пошта",
|
||||
"privacy": "Конфіденційність і безпека",
|
||||
"advanced": "Додатково"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Зовнішній вигляд",
|
||||
@@ -1117,7 +1127,7 @@
|
||||
"description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
|
||||
"name_label": "Назва ключа",
|
||||
"name_placeholder": "напр. Скрипт резервної копії, CI runner",
|
||||
"copy_now_warning": "Скопіюйте цей API-ключ зараз — він більше не буде показаний.",
|
||||
"copy_now_warning": "Скопіюйте цей API-ключ зараз – він більше не буде показаний.",
|
||||
"added": "API-ключ створено",
|
||||
"removed": "API-ключ видалено",
|
||||
"add_error": "Не вдалося створити API-ключ",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
|
||||
"empty_search": "Жоден контакт не відповідає вашому пошуку",
|
||||
"empty_search_hint": "Спробуйте інший термін пошуку",
|
||||
"empty_filtered": "Жоден контакт не відповідає фільтрам",
|
||||
"empty_filtered_hint": "Змініть або очистіть фільтри",
|
||||
"clear_search": "Очистити пошук",
|
||||
"import_vcard": "Імпорт vCard",
|
||||
"delete_confirm_title": "Видалити контакт",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"calendar": "Календар",
|
||||
"calendar_uri": "URL-адреса календаря",
|
||||
"scheduling_uri": "URL-адреса планування",
|
||||
"freebusy_uri": "Вільний/зайнятий URL"
|
||||
"freebusy_uri": "Вільний/зайнятий URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Більше дій",
|
||||
"age_years": "{count, plural, one {# рік} few {# роки} many {# років} other {# років}}",
|
||||
"years_since": "{count, plural, one {# рік тому} few {# роки тому} many {# років тому} other {# років тому}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Новий контакт",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "Введіть дійсну електронну адресу",
|
||||
"email_error_inline": "Недійсний формат електронної пошти",
|
||||
"save_failed": "Не вдалося зберегти контакт",
|
||||
"delete": "Видалити"
|
||||
"delete": "Видалити",
|
||||
"upload_photo": "Завантажити фото",
|
||||
"remove_photo": "Видалити фото",
|
||||
"photo_hint": "JPG або PNG, до 10 МБ. Буде зменшено.",
|
||||
"photo_too_large": "Зображення завелике (макс. 10 МБ)",
|
||||
"photo_invalid": "Недійсний файл зображення",
|
||||
"change_photo": "Змінити"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Нова група",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "Не вдалося створити контакт",
|
||||
"error_update": "Не вдалося оновити контакт",
|
||||
"error_delete": "Не вдалося видалити контакт"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Відкрити",
|
||||
"edit": "Редагувати",
|
||||
"send_email": "Надіслати лист",
|
||||
"add_to_group": "Додати до групи",
|
||||
"export_vcard": "Експортувати як vCard",
|
||||
"delete": "Видалити",
|
||||
"call": "Зателефонувати",
|
||||
"duplicate": "Дублювати",
|
||||
"print": "Друк"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Фільтри",
|
||||
"select": "Вибрати",
|
||||
"clear": "Очистити",
|
||||
"close": "Закрити",
|
||||
"title": "Розширені фільтри",
|
||||
"organization": "Компанія",
|
||||
"organization_placeholder": "напр. Acme",
|
||||
"job_title": "Посада",
|
||||
"job_title_placeholder": "напр. Дизайнер",
|
||||
"location": "Місцезнаходження",
|
||||
"location_placeholder": "Місто або країна",
|
||||
"email_domain": "Домен e-mail",
|
||||
"email_domain_placeholder": "priklad.ua",
|
||||
"birthday_month": "День народження в",
|
||||
"any_month": "Будь-який місяць",
|
||||
"has_email": "З ел. поштою",
|
||||
"has_phone": "З телефоном",
|
||||
"has_photo": "З фото"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "Орієнтовний",
|
||||
"needs_action": "Потребує дії",
|
||||
"remove": "видалити",
|
||||
"edit": "Редагувати",
|
||||
"email_placeholder": "Додайте електронну адресу або знайдіть контакти",
|
||||
"send_invitations": "Надішліть запрошення учасникам",
|
||||
"status_summary": "{accepted} прийнято, {pending} очікує на розгляд",
|
||||
|
||||
@@ -671,14 +671,24 @@
|
||||
"contacts": "联系人",
|
||||
"encryption": "加密",
|
||||
"sidebar_apps": "侧边栏应用",
|
||||
"notifications": "通知"
|
||||
"notifications": "通知",
|
||||
"layout": "布局",
|
||||
"reading": "阅读",
|
||||
"composing": "撰写",
|
||||
"content_senders": "内容和发件人",
|
||||
"about_data": "关于和数据",
|
||||
"debug": "调试"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "通用",
|
||||
"account": "账户与身份",
|
||||
"organization": "邮件组织",
|
||||
"apps": "应用",
|
||||
"system": "系统"
|
||||
"system": "系统",
|
||||
"appearance": "外观",
|
||||
"mail": "邮件",
|
||||
"privacy": "隐私和安全",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外观",
|
||||
@@ -1737,6 +1747,8 @@
|
||||
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
|
||||
"empty_search": "没有符合您搜索条件的联系人",
|
||||
"empty_search_hint": "尝试不同的搜索词",
|
||||
"empty_filtered": "没有符合您筛选条件的联系人",
|
||||
"empty_filtered_hint": "请调整或清除筛选条件",
|
||||
"clear_search": "清除搜索",
|
||||
"import_vcard": "导入 vCard",
|
||||
"delete_confirm_title": "删除联系人",
|
||||
@@ -1814,7 +1826,22 @@
|
||||
"calendar": "日历",
|
||||
"calendar_uri": "日历 URL",
|
||||
"scheduling_uri": "调度 URL",
|
||||
"freebusy_uri": "空闲/忙碌 URL"
|
||||
"freebusy_uri": "空闲/忙碌 URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "更多操作",
|
||||
"age_years": "{count}岁",
|
||||
"years_since": "{count}年前"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
@@ -1823,7 +1850,9 @@
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load"
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新联系人",
|
||||
@@ -1919,7 +1948,13 @@
|
||||
"email_invalid": "请输入有效的邮箱地址",
|
||||
"email_error_inline": "邮箱地址格式无效",
|
||||
"save_failed": "保存联系人失败",
|
||||
"delete": "删除"
|
||||
"delete": "删除",
|
||||
"upload_photo": "上传照片",
|
||||
"remove_photo": "移除照片",
|
||||
"photo_hint": "JPG 或 PNG, 最大 10MB。将会调整大小。",
|
||||
"photo_too_large": "图像过大 (最大 10MB)",
|
||||
"photo_invalid": "无效的图像文件",
|
||||
"change_photo": "更改"
|
||||
},
|
||||
"groups": {
|
||||
"create": "新建群组",
|
||||
@@ -1979,6 +2014,37 @@
|
||||
"error_create": "创建联系人失败",
|
||||
"error_update": "无法更新联系人",
|
||||
"error_delete": "删除联系人失败"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "打开",
|
||||
"edit": "编辑",
|
||||
"send_email": "发送邮件",
|
||||
"add_to_group": "加入群组",
|
||||
"export_vcard": "导出为 vCard",
|
||||
"delete": "删除",
|
||||
"call": "拨打电话",
|
||||
"duplicate": "复制",
|
||||
"print": "打印"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "筛选",
|
||||
"select": "选择",
|
||||
"clear": "清除",
|
||||
"close": "关闭",
|
||||
"title": "高级筛选",
|
||||
"organization": "公司",
|
||||
"organization_placeholder": "例如 Acme",
|
||||
"job_title": "职位",
|
||||
"job_title_placeholder": "例如 设计师",
|
||||
"location": "所在地",
|
||||
"location_placeholder": "城市或国家",
|
||||
"email_domain": "邮箱域名",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "生日月份",
|
||||
"any_month": "任意月份",
|
||||
"has_email": "有邮箱",
|
||||
"has_phone": "有电话",
|
||||
"has_photo": "有照片"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2054,6 +2120,7 @@
|
||||
"tentative": "暂定",
|
||||
"needs_action": "待处理",
|
||||
"remove": "移除",
|
||||
"edit": "编辑",
|
||||
"email_placeholder": "添加邮箱地址或搜索联系人",
|
||||
"send_invitations": "向参与者发送邀请",
|
||||
"status_summary": "{accepted} 已接受,{pending} 待处理",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.14",
|
||||
"version": "1.5.0",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -32,62 +32,62 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tiptap/extension-color": "^3.20.4",
|
||||
"@tiptap/extension-image": "^3.20.4",
|
||||
"@tiptap/extension-link": "^3.20.4",
|
||||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
"@tiptap/extension-text-align": "^3.20.4",
|
||||
"@tiptap/extension-text-style": "^3.20.4",
|
||||
"@tiptap/extension-underline": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/react": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"asn1js": "^3.0.7",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tiptap/extension-color": "^3.22.4",
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-placeholder": "^3.22.4",
|
||||
"@tiptap/extension-text-align": "^3.22.4",
|
||||
"@tiptap/extension-text-style": "^3.22.4",
|
||||
"@tiptap/extension-underline": "^3.22.4",
|
||||
"@tiptap/pm": "^3.22.4",
|
||||
"@tiptap/react": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^16.1.5",
|
||||
"next-intl": "^4.5.8",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^16.2.4",
|
||||
"next-intl": "^4.9.1",
|
||||
"otpauth": "^9.5.0",
|
||||
"pkijs": "^3.3.3",
|
||||
"pkijs": "^3.4.0",
|
||||
"postal-mime": "^2.7.4",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"webcrypto-liner": "^1.4.3",
|
||||
"zustand": "^5.0.9"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"eslint": "^9.39.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^17.0.0",
|
||||
"globals": "^17.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"elliptic": {
|
||||
|
||||
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -53,6 +53,7 @@ ENV_FILE="${SCRIPT_DIR}/.env.local"
|
||||
# Config values (defaults)
|
||||
CFG_APP_NAME="Bulwark Webmail"
|
||||
CFG_JMAP_SERVER_URL=""
|
||||
CFG_ALLOW_CUSTOM_JMAP_ENDPOINT="false"
|
||||
CFG_STALWART_FEATURES="true"
|
||||
CFG_OAUTH_ENABLED="false"
|
||||
CFG_OAUTH_ONLY="false"
|
||||
@@ -64,16 +65,23 @@ CFG_SETTINGS_SYNC_ENABLED="false"
|
||||
CFG_SETTINGS_DATA_DIR="./data/settings"
|
||||
CFG_LOG_FORMAT="text"
|
||||
CFG_LOG_LEVEL="info"
|
||||
CFG_APP_SHORT_NAME=""
|
||||
CFG_APP_DESCRIPTION=""
|
||||
CFG_LOGIN_COMPANY_NAME=""
|
||||
CFG_LOGIN_LOGO_LIGHT_URL=""
|
||||
CFG_LOGIN_LOGO_DARK_URL=""
|
||||
CFG_FAVICON_URL=""
|
||||
CFG_PWA_ICON_URL=""
|
||||
CFG_PWA_THEME_COLOR=""
|
||||
CFG_PWA_BACKGROUND_COLOR=""
|
||||
CFG_APP_LOGO_LIGHT_URL=""
|
||||
CFG_APP_LOGO_DARK_URL=""
|
||||
CFG_LOGIN_IMPRINT_URL=""
|
||||
CFG_LOGIN_PRIVACY_POLICY_URL=""
|
||||
CFG_LOGIN_WEBSITE_URL=""
|
||||
CFG_EXTENSION_DIRECTORY_URL=""
|
||||
CFG_DEPLOY_METHOD=""
|
||||
CFG_HOSTNAME="0.0.0.0"
|
||||
CFG_PORT="3000"
|
||||
|
||||
CURRENT_STEP=0
|
||||
@@ -593,9 +601,19 @@ screen_server_config() {
|
||||
prompt_yesno "Enable Stalwart-specific features?" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Network${RESET}"
|
||||
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}"
|
||||
echo -e " ${DIM}Adds a \"JMAP Server\" field to the login form so users can${RESET}"
|
||||
echo -e " ${DIM}connect to any JMAP-compatible server (not just the one above).${RESET}"
|
||||
echo -e " ${DIM}The external server must allow this domain in its CORS headers.${RESET}"
|
||||
prompt_yesno "Allow users to override the JMAP server at login?" "$CFG_ALLOW_CUSTOM_JMAP_ENDPOINT" "CFG_ALLOW_CUSTOM_JMAP_ENDPOINT"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Network${RESET}"
|
||||
echo -e " ${DIM}The address the server binds to. Use \"0.0.0.0\" for all IPv4${RESET}"
|
||||
echo -e " ${DIM}interfaces or \"::\" for dual-stack IPv4+IPv6.${RESET}"
|
||||
echo ""
|
||||
prompt_value "Hostname" "$CFG_HOSTNAME" "CFG_HOSTNAME"
|
||||
echo ""
|
||||
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}"
|
||||
prompt_value "Port" "$CFG_PORT" "CFG_PORT"
|
||||
|
||||
draw_footer
|
||||
@@ -770,32 +788,65 @@ screen_login_customization() {
|
||||
echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field."
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}App Identity${RESET}"
|
||||
echo -e " ${DIM}Short name used where space is limited (e.g. mobile home screen).${RESET}"
|
||||
echo -e " ${DIM}Defaults to the application name set in step 1.${RESET}"
|
||||
prompt_value "Short app name" "$CFG_APP_SHORT_NAME" "CFG_APP_SHORT_NAME"
|
||||
echo ""
|
||||
echo -e " ${DIM}Description shown by the OS when installing as a PWA.${RESET}"
|
||||
prompt_value "App description" "$CFG_APP_DESCRIPTION" "CFG_APP_DESCRIPTION"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Icons${RESET}"
|
||||
echo -e " ${DIM}Custom favicon for the browser tab (SVG recommended, 32×32 to 512×512px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}"
|
||||
prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL"
|
||||
echo ""
|
||||
echo -e " ${DIM}Source image for PWA icons (192×192 and 512×512 are auto-generated).${RESET}"
|
||||
echo -e " ${DIM}Falls back to FAVICON_URL if blank.${RESET}"
|
||||
prompt_value "PWA icon URL" "$CFG_PWA_ICON_URL" "CFG_PWA_ICON_URL"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}PWA Colors${RESET}"
|
||||
echo -e " ${DIM}Hex color (e.g. #3b82f6) used for the browser UI chrome when installed.${RESET}"
|
||||
prompt_value "PWA theme color" "$CFG_PWA_THEME_COLOR" "CFG_PWA_THEME_COLOR"
|
||||
echo ""
|
||||
echo -e " ${DIM}Hex color shown on the PWA splash screen while loading.${RESET}"
|
||||
prompt_value "PWA background color" "$CFG_PWA_BACKGROUND_COLOR" "CFG_PWA_BACKGROUND_COLOR"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Logos${RESET}"
|
||||
echo -e " ${DIM}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank for no sidebar logo.${RESET}"
|
||||
prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL"
|
||||
echo ""
|
||||
prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL"
|
||||
echo ""
|
||||
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
|
||||
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
|
||||
echo ""
|
||||
echo -e " ${DIM}Custom logo URLs for the login page (SVG recommended, 32×32 to 512×512px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank to use the default Bulwark logo.${RESET}"
|
||||
prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
|
||||
echo ""
|
||||
prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Login Page Links${RESET}"
|
||||
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
|
||||
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
|
||||
echo ""
|
||||
prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL"
|
||||
echo ""
|
||||
prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL"
|
||||
echo ""
|
||||
prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL"
|
||||
echo ""
|
||||
|
||||
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
echo -e " ${BOLD}Extension Directory${RESET}"
|
||||
echo -e " ${DIM}URL of the BulwarkMail extension directory for the admin marketplace.${RESET}"
|
||||
echo -e " ${DIM}Set this to enable browsing and installing plugins/themes.${RESET}"
|
||||
echo -e " ${DIM}Leave blank to disable the marketplace.${RESET}"
|
||||
prompt_value "Extension directory URL" "$CFG_EXTENSION_DIRECTORY_URL" "CFG_EXTENSION_DIRECTORY_URL"
|
||||
|
||||
if [[ -z "$CFG_APP_SHORT_NAME" && -z "$CFG_APP_DESCRIPTION" && -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_PWA_ICON_URL" && -z "$CFG_PWA_THEME_COLOR" && -z "$CFG_PWA_BACKGROUND_COLOR" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" && -z "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
|
||||
echo ""
|
||||
note "No branding configured. The app will use defaults."
|
||||
fi
|
||||
@@ -866,7 +917,9 @@ screen_summary() {
|
||||
echo -e " ${CYAN}${BOLD}SERVER${RESET}"
|
||||
echo -e " App Name .............. ${BOLD}${CFG_APP_NAME}${RESET}"
|
||||
echo -e " JMAP Server URL ....... ${BOLD}${CFG_JMAP_SERVER_URL}${RESET}"
|
||||
echo -e " Allow Custom JMAP ..... ${BOLD}${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}${RESET}"
|
||||
echo -e " Stalwart Features ..... ${BOLD}${CFG_STALWART_FEATURES}${RESET}"
|
||||
echo -e " Hostname .............. ${BOLD}${CFG_HOSTNAME}${RESET}"
|
||||
echo -e " Port .................. ${BOLD}${CFG_PORT}${RESET}"
|
||||
echo ""
|
||||
|
||||
@@ -905,9 +958,19 @@ screen_summary() {
|
||||
|
||||
# Login page
|
||||
echo -e " ${CYAN}${BOLD}BRANDING${RESET}"
|
||||
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
if [[ -n "$CFG_APP_SHORT_NAME" || -n "$CFG_APP_DESCRIPTION" || -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_PWA_ICON_URL" || -n "$CFG_PWA_THEME_COLOR" || -n "$CFG_PWA_BACKGROUND_COLOR" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
[[ -n "$CFG_APP_SHORT_NAME" ]] && \
|
||||
echo -e " Short Name ............ ${BOLD}${CFG_APP_SHORT_NAME}${RESET}"
|
||||
[[ -n "$CFG_APP_DESCRIPTION" ]] && \
|
||||
echo -e " Description ........... ${BOLD}${CFG_APP_DESCRIPTION}${RESET}"
|
||||
[[ -n "$CFG_FAVICON_URL" ]] && \
|
||||
echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}"
|
||||
[[ -n "$CFG_PWA_ICON_URL" ]] && \
|
||||
echo -e " PWA Icon URL .......... ${BOLD}${CFG_PWA_ICON_URL}${RESET}"
|
||||
[[ -n "$CFG_PWA_THEME_COLOR" ]] && \
|
||||
echo -e " PWA Theme Color ....... ${BOLD}${CFG_PWA_THEME_COLOR}${RESET}"
|
||||
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && \
|
||||
echo -e " PWA Background Color .. ${BOLD}${CFG_PWA_BACKGROUND_COLOR}${RESET}"
|
||||
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \
|
||||
echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}"
|
||||
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \
|
||||
@@ -929,6 +992,13 @@ screen_summary() {
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Extensions
|
||||
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
|
||||
echo -e " ${CYAN}${BOLD}EXTENSIONS${RESET}"
|
||||
echo -e " Directory URL ......... ${BOLD}${CFG_EXTENSION_DIRECTORY_URL}${RESET}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Deployment
|
||||
echo -e " ${CYAN}${BOLD}DEPLOYMENT${RESET}"
|
||||
case "$CFG_DEPLOY_METHOD" in
|
||||
@@ -978,10 +1048,15 @@ write_env_file() {
|
||||
# -- JMAP Server (required) ---------------------------------------------------
|
||||
APP_NAME=${CFG_APP_NAME}
|
||||
JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL}
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}
|
||||
|
||||
# -- Stalwart Mail Server Integration -----------------------------------------
|
||||
STALWART_FEATURES=${CFG_STALWART_FEATURES}
|
||||
|
||||
# -- Server Listen Address ----------------------------------------------------
|
||||
HOSTNAME=${CFG_HOSTNAME}
|
||||
PORT=${CFG_PORT}
|
||||
|
||||
# -- OAuth / OpenID Connect ---------------------------------------------------
|
||||
OAUTH_ENABLED=${CFG_OAUTH_ENABLED}
|
||||
ENVEOF
|
||||
@@ -1025,7 +1100,12 @@ LOG_LEVEL=${CFG_LOG_LEVEL}
|
||||
# -- Branding ------------------------------------------------------------------
|
||||
ENVEOF
|
||||
|
||||
[[ -n "$CFG_APP_SHORT_NAME" ]] && echo "APP_SHORT_NAME=${CFG_APP_SHORT_NAME}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_DESCRIPTION" ]] && echo "APP_DESCRIPTION=${CFG_APP_DESCRIPTION}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_FAVICON_URL" ]] && echo "FAVICON_URL=${CFG_FAVICON_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_PWA_ICON_URL" ]] && echo "PWA_ICON_URL=${CFG_PWA_ICON_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_PWA_THEME_COLOR" ]] && echo "PWA_THEME_COLOR=${CFG_PWA_THEME_COLOR}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && echo "PWA_BACKGROUND_COLOR=${CFG_PWA_BACKGROUND_COLOR}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && echo "APP_LOGO_LIGHT_URL=${CFG_APP_LOGO_LIGHT_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && echo "APP_LOGO_DARK_URL=${CFG_APP_LOGO_DARK_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE"
|
||||
@@ -1035,6 +1115,29 @@ ENVEOF
|
||||
[[ -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]] && echo "LOGIN_PRIVACY_POLICY_URL=${CFG_LOGIN_PRIVACY_POLICY_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && echo "LOGIN_WEBSITE_URL=${CFG_LOGIN_WEBSITE_URL}" >> "$ENV_FILE"
|
||||
|
||||
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
|
||||
cat >> "$ENV_FILE" << ENVEOF
|
||||
|
||||
# -- Extension Directory / Marketplace ----------------------------------------
|
||||
EXTENSION_DIRECTORY_URL=${CFG_EXTENSION_DIRECTORY_URL}
|
||||
ENVEOF
|
||||
fi
|
||||
|
||||
cat >> "$ENV_FILE" << ENVEOF
|
||||
|
||||
# -- Advanced (uncomment and set if needed) -----------------------------------
|
||||
# SESSION_SECRET_FILE=/run/secrets/session_secret
|
||||
# OAUTH_CLIENT_SECRET_FILE=/run/secrets/oauth_client_secret
|
||||
# OAUTH_SCOPES=openid profile email
|
||||
# OAUTH_EXTRA_SCOPES=
|
||||
# COOKIE_SAME_SITE=lax
|
||||
# COOKIE_SECURE=true
|
||||
# TRUSTED_PROXY_DEPTH=1
|
||||
# ALLOWED_FRAME_ANCESTORS='none'
|
||||
# ADMIN_DATA_DIR=./data/admin
|
||||
# ADMIN_SESSION_TTL=3600
|
||||
ENVEOF
|
||||
|
||||
echo -e " ${OK} Written ${BOLD}.env.local${RESET}"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,857 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
Internet Engineering Task Force (IETF) N. Jenkins, Ed.
|
||||
Request for Comments: 9670 Fastmail
|
||||
Updates: 8620 November 2024
|
||||
Category: Standards Track
|
||||
ISSN: 2070-1721
|
||||
|
||||
|
||||
JSON Meta Application Protocol (JMAP) Sharing
|
||||
|
||||
Abstract
|
||||
|
||||
This document specifies a data model for sharing data between users
|
||||
using the JSON Meta Application Protocol (JMAP). Future documents
|
||||
can reference this document when defining data types to support a
|
||||
consistent model of sharing.
|
||||
|
||||
Status of This Memo
|
||||
|
||||
This is an Internet Standards Track document.
|
||||
|
||||
This document is a product of the Internet Engineering Task Force
|
||||
(IETF). It represents the consensus of the IETF community. It has
|
||||
received public review and has been approved for publication by the
|
||||
Internet Engineering Steering Group (IESG). Further information on
|
||||
Internet Standards is available in Section 2 of RFC 7841.
|
||||
|
||||
Information about the current status of this document, any errata,
|
||||
and how to provide feedback on it may be obtained at
|
||||
https://www.rfc-editor.org/info/rfc9670.
|
||||
|
||||
Copyright Notice
|
||||
|
||||
Copyright (c) 2024 IETF Trust and the persons identified as the
|
||||
document authors. All rights reserved.
|
||||
|
||||
This document is subject to BCP 78 and the IETF Trust's Legal
|
||||
Provisions Relating to IETF Documents
|
||||
(https://trustee.ietf.org/license-info) in effect on the date of
|
||||
publication of this document. Please review these documents
|
||||
carefully, as they describe your rights and restrictions with respect
|
||||
to this document. Code Components extracted from this document must
|
||||
include Revised BSD License text as described in Section 4.e of the
|
||||
Trust Legal Provisions and are provided without warranty as described
|
||||
in the Revised BSD License.
|
||||
|
||||
Table of Contents
|
||||
|
||||
1. Introduction
|
||||
1.1. Notational Conventions
|
||||
1.2. Terminology
|
||||
1.3. Data Model Overview
|
||||
1.4. Subscribing to Shared Data
|
||||
1.5. Addition to the Capabilities Object
|
||||
1.5.1. urn:ietf:params:jmap:principals
|
||||
1.5.2. urn:ietf:params:jmap:principals:owner
|
||||
2. Principals
|
||||
2.1. Principal/get
|
||||
2.2. Principal/changes
|
||||
2.3. Principal/set
|
||||
2.4. Principal/query
|
||||
2.4.1. Filtering
|
||||
2.5. Principal/queryChanges
|
||||
3. ShareNotifications
|
||||
3.1. ShareNotification/get
|
||||
3.2. ShareNotification/changes
|
||||
3.3. ShareNotification/set
|
||||
3.4. ShareNotification/query
|
||||
3.4.1. Filtering
|
||||
3.4.2. Sorting
|
||||
3.5. ShareNotification/queryChanges
|
||||
4. Framework for Shared Data
|
||||
4.1. Example
|
||||
5. Internationalization Considerations
|
||||
6. Security Considerations
|
||||
6.1. Spoofing
|
||||
6.2. Unnoticed Sharing
|
||||
6.3. Denial of Service
|
||||
6.4. Unauthorized Principals
|
||||
7. IANA Considerations
|
||||
7.1. JMAP Capability Registration for "principals"
|
||||
7.2. JMAP Capability Registration for "principals:owner"
|
||||
7.3. JMAP Data Type Registration for "Principal"
|
||||
7.4. JMAP Data Type Registration for "ShareNotification"
|
||||
8. References
|
||||
8.1. Normative References
|
||||
8.2. Informative References
|
||||
Author's Address
|
||||
|
||||
1. Introduction
|
||||
|
||||
The JSON Meta Application Protocol (JMAP) [RFC8620] is a generic
|
||||
protocol for synchronizing data, such as mail, calendars, or
|
||||
contacts, between a client and a server. It is optimized for mobile
|
||||
and web environments and provides a consistent interface to query,
|
||||
read, and modify different data types, including comprehensive error
|
||||
handling.
|
||||
|
||||
This specification defines a data model to represent entities in a
|
||||
collaborative environment and a framework for sharing data between
|
||||
them that can be used to provide a consistent sharing model for
|
||||
different data types. It does not define _what_ may be shared or the
|
||||
granularity of permissions, as this will depend on the data in
|
||||
question.
|
||||
|
||||
1.1. Notational Conventions
|
||||
|
||||
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
|
||||
"SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and
|
||||
"OPTIONAL" in this document are to be interpreted as described in
|
||||
BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all
|
||||
capitals, as shown here.
|
||||
|
||||
Type signatures, examples, and property descriptions in this document
|
||||
follow the conventions established in Section 1.1 of [RFC8620]. Data
|
||||
types defined in the core specification are also used in this
|
||||
document.
|
||||
|
||||
Examples of API exchanges only show the methodCalls array of the
|
||||
Request object or the methodResponses array of the Response object.
|
||||
For compactness, the rest of the Request/Response object is omitted.
|
||||
|
||||
1.2. Terminology
|
||||
|
||||
The same terminology is used in this document as in the core JMAP
|
||||
specification. See [RFC8620], Section 1.6.
|
||||
|
||||
The terms "Principal" and "ShareNotification" (with this specific
|
||||
capitalization) are used to refer to the data types defined in this
|
||||
document and instances of those data types.
|
||||
|
||||
1.3. Data Model Overview
|
||||
|
||||
A Principal (see Section 2) represents an individual, team, or
|
||||
resource (e.g., a room or projector). The object contains
|
||||
information about the entity being represented, such as a name,
|
||||
description, and time zone. It may also hold domain-specific
|
||||
information. A Principal may be associated with zero or more
|
||||
Accounts (see [RFC8620], Section 1.6.2) containing data belonging to
|
||||
the Principal. Managing the set of Principals within a system is out
|
||||
of scope for this specification, as it is highly domain specific. It
|
||||
is likely to map directly from a directory service or other user
|
||||
management system.
|
||||
|
||||
Data types may allow users to share data with others by assigning
|
||||
permissions to Principals. When a user's permissions are changed, a
|
||||
ShareNotification object is created for them so a client can inform
|
||||
the user of the changes.
|
||||
|
||||
1.4. Subscribing to Shared Data
|
||||
|
||||
Permissions determine whether a user _may_ access data but not
|
||||
whether they _want_ to. Some shared data is of equal importance as
|
||||
the user's own, while other data is just there should the user wish
|
||||
to explicitly go find it. Clients will often want to differentiate
|
||||
the two. For example, a company may share mailing list archives for
|
||||
all departments with all employees, but a user may only generally be
|
||||
interested in the few they belong to. They would have _permission_
|
||||
to access many mailboxes but can _subscribe_ to just the ones they
|
||||
care about. The client would provide separate interfaces for reading
|
||||
mail in subscribed mailboxes and browsing all mailboxes they have
|
||||
permission to access in order to manage those that they are
|
||||
subscribed to.
|
||||
|
||||
The JMAP Session object (see [RFC8620], Section 2) is defined to
|
||||
include an object in the "accounts" property for every Account that
|
||||
the user has access to. Collaborative systems may share data between
|
||||
a very large number of Principals, most of which the user does not
|
||||
care about day to day. For servers implementing this specification,
|
||||
the Session object MUST only include Accounts where either the user
|
||||
is subscribed to at least one record (see [RFC8620], Section 1.6.3)
|
||||
in the Account or the Account belongs to the user. StateChange
|
||||
events ([RFC8620], Section 7.1) for changes to data SHOULD only be
|
||||
sent for data the user has subscribed to and MUST NOT be sent for any
|
||||
Account where the user is not subscribed to any records in the
|
||||
Account, except where that Account belongs to the user.
|
||||
|
||||
The server MAY reject the user's attempt to subscribe to some
|
||||
resources even if they have permission to access them (e.g., a
|
||||
calendar representing a location).
|
||||
|
||||
A user can query the set of Principals they have access to with
|
||||
"Principal/query" (see Section 2.4). The Principal object will
|
||||
contain an Account object for all Accounts where the user has
|
||||
permission to access data for that Principal, even if they are not
|
||||
yet subscribed.
|
||||
|
||||
1.5. Addition to the Capabilities Object
|
||||
|
||||
The capabilities object is returned as part of the JMAP Session
|
||||
object; see [RFC8620], Section 2. This document defines two
|
||||
additional capability URIs.
|
||||
|
||||
1.5.1. urn:ietf:params:jmap:principals
|
||||
|
||||
The urn:ietf:params:jmap:principals capability represents support for
|
||||
the Principal and ShareNotification data types and associated API
|
||||
methods.
|
||||
|
||||
The value of this property in the JMAP Session "capabilities"
|
||||
property is an empty object.
|
||||
|
||||
The value of this property in an Account's "accountCapabilities"
|
||||
property is an object that MUST contain the following information on
|
||||
server capabilities and permissions for that Account:
|
||||
|
||||
*currentUserPrincipalId*: Id|null
|
||||
The id of the Principal in this Account that corresponds to the
|
||||
user fetching this object, if any.
|
||||
|
||||
1.5.2. urn:ietf:params:jmap:principals:owner
|
||||
|
||||
The URI urn:ietf:params:jmap:principals:owner is solely used as a key
|
||||
in an Account's "accountCapabilities" property. It does not appear
|
||||
in the JMAP Session capabilities -- support is indicated by the
|
||||
urn:ietf:params:jmap:principals URI being present in the session
|
||||
capabilities.
|
||||
|
||||
If urn:ietf:params:jmap:principals:owner is a key in an Account's
|
||||
"accountCapabilities" property, that Account (and the data therein)
|
||||
is owned by a Principal. Some Accounts may not be owned by a
|
||||
Principal (e.g., the Account that contains the data for the
|
||||
Principals themselves), in which case this property is omitted.
|
||||
|
||||
The value of this property is an object with the following
|
||||
properties:
|
||||
|
||||
*accountIdForPrincipal*: Id
|
||||
The id of an Account with the urn:ietf:params:jmap:principals
|
||||
capability that contains the corresponding Principal object.
|
||||
|
||||
*principalId*:Id
|
||||
The id of the Principal that owns this Account.
|
||||
|
||||
2. Principals
|
||||
|
||||
A Principal represents an individual, a group, a location (e.g., a
|
||||
room), a resource (e.g., a projector), or another entity in a
|
||||
collaborative environment. Sharing in JMAP is generally configured
|
||||
by assigning rights to certain data within an Account to other
|
||||
Principals. For example, a user may assign permission to read their
|
||||
calendar to a Principal representing another user or their team.
|
||||
|
||||
In a shared environment, such as a workplace, a user may have access
|
||||
to a large number of Principals.
|
||||
|
||||
In most systems, the user will have access to a single Account
|
||||
containing Principal objects. In some situations, for example, when
|
||||
aggregating data from different places, there may be multiple
|
||||
Accounts containing Principal objects.
|
||||
|
||||
A *Principal* object has the following properties:
|
||||
|
||||
*id*: Id (immutable; server-set)
|
||||
The id of the Principal.
|
||||
|
||||
*type*: String
|
||||
This MUST be one of the following values:
|
||||
|
||||
* "individual": This represents a single person.
|
||||
* "group": This represents a group of other Principals.
|
||||
* "resource": This represents some resource, e.g., a projector.
|
||||
* "location": This represents a location.
|
||||
* "other": This represents some other undefined Principal.
|
||||
|
||||
*name*: String
|
||||
The name of the Principal, e.g., "Jane Doe" or "Room 4B".
|
||||
|
||||
*description*: String|null
|
||||
A longer description of the Principal, for example, details about
|
||||
the facilities of a resource, or null if no description is
|
||||
available.
|
||||
|
||||
*email*: String|null
|
||||
An email address for the Principal, or null if no email is
|
||||
available. If given, the value MUST conform to the "addr-spec"
|
||||
syntax, as defined in [RFC5322], Section 3.4.1.
|
||||
|
||||
*timeZone*: String|null
|
||||
The time zone for this Principal, if known. If not null, the
|
||||
value MUST be a time zone name from the IANA Time Zone Database
|
||||
[IANA-TZDB].
|
||||
|
||||
*capabilities*: String[Object] (server-set)
|
||||
A map of JMAP capability URIs to domain-specific information about
|
||||
the Principal in relation to that capability, as defined in the
|
||||
document that registered the capability.
|
||||
|
||||
*accounts*: Id[Account]|null (server-set)
|
||||
A map of Account id to Account object for each JMAP Account
|
||||
containing data for this Principal that the user has access to, or
|
||||
null if none.
|
||||
|
||||
2.1. Principal/get
|
||||
|
||||
This is a standard "/get" method as described in [RFC8620],
|
||||
Section 5.1.
|
||||
|
||||
2.2. Principal/changes
|
||||
|
||||
This is a standard "/changes" method as described in [RFC8620],
|
||||
Section 5.2.
|
||||
|
||||
| Note: Implementations backed by an external directory may be
|
||||
| unable to calculate changes. In this case, they will always
|
||||
| return a "cannotCalculateChanges" error as described in the
|
||||
| core JMAP specification.
|
||||
|
||||
2.3. Principal/set
|
||||
|
||||
This is a standard "/set" method as described in [RFC8620],
|
||||
Section 5.3.
|
||||
|
||||
Managing Principals is likely tied to a directory service or some
|
||||
other vendor-specific solution. This management may occur out of
|
||||
band or via an additional capability defined elsewhere. Allowing
|
||||
direct user modification of properties has security considerations,
|
||||
as noted in Section 6. A server MUST reject any change it doesn't
|
||||
allow with a "forbidden" SetError.
|
||||
|
||||
Where a server does support changes via this API, it SHOULD allow an
|
||||
update to the "name", "description", and "timeZone" properties of the
|
||||
Principal with the same id as the "currentUserPrincipalId" in the
|
||||
Account capabilities. This allows the user to update their own
|
||||
details.
|
||||
|
||||
2.4. Principal/query
|
||||
|
||||
This is a standard "/query" method as described in [RFC8620],
|
||||
Section 5.5.
|
||||
|
||||
2.4.1. Filtering
|
||||
|
||||
A *FilterCondition* object has the following properties, all of which
|
||||
are optional:
|
||||
|
||||
*accountIds*: String[]
|
||||
A list of Account ids. The Principal matches if any of the ids in
|
||||
this list are keys in the Principal's "accounts" property (i.e.,
|
||||
if any of the Account ids belong to the Principal).
|
||||
|
||||
*email*: String
|
||||
The email property of the Principal contains the given string.
|
||||
|
||||
*name*: String
|
||||
The name property of the Principal contains the given string.
|
||||
|
||||
*text*: String
|
||||
The name, email, or description property of the Principal contains
|
||||
the given string.
|
||||
|
||||
*type*: String
|
||||
The type must be exactly as given to match the condition.
|
||||
|
||||
*timeZone*: String
|
||||
The timeZone must be exactly as given to match the condition.
|
||||
|
||||
All given conditions in the FilterCondition object must match for the
|
||||
Principal to match.
|
||||
|
||||
Text matches for "contains" SHOULD be simple substring matches.
|
||||
|
||||
2.5. Principal/queryChanges
|
||||
|
||||
This is a standard "/queryChanges" method as described in [RFC8620],
|
||||
Section 5.6.
|
||||
|
||||
| Note: Implementations backed by an external directory may be
|
||||
| unable to calculate changes. In this case, they will always
|
||||
| return a "cannotCalculateChanges" error as described in the
|
||||
| core JMAP specification.
|
||||
|
||||
3. ShareNotifications
|
||||
|
||||
The ShareNotification data type records when the user's permissions
|
||||
to access a shared object changes. ShareNotifications are only
|
||||
created by the server; users cannot create them explicitly. They are
|
||||
stored in the same Account as the Principals.
|
||||
|
||||
Clients may present the list of notifications to the user and allow
|
||||
the user to dismiss them. To dismiss a notification, use a standard
|
||||
"/set" call to destroy it.
|
||||
|
||||
The server SHOULD create a ShareNotification whenever the user's
|
||||
permissions change on an object. It MAY choose not to create a
|
||||
notification for permission changes to a group Principal, even if the
|
||||
user is in the group, if this is more likely to be overwhelming than
|
||||
helpful, or if it would create excessive notifications within the
|
||||
system.
|
||||
|
||||
The server MAY limit the maximum number of notifications it will
|
||||
store for a user. When the limit is reached, any new notification
|
||||
will cause the previously oldest notification to be automatically
|
||||
deleted.
|
||||
|
||||
The server MAY coalesce notifications if appropriate or remove
|
||||
notifications after a certain period of time or that it deems are no
|
||||
longer relevant.
|
||||
|
||||
A *ShareNotification* object has the following properties:
|
||||
|
||||
*id*: String (immutable; server-set)
|
||||
The id of the ShareNotification.
|
||||
|
||||
*created*: UTCDate (immutable; server-set)
|
||||
The time this notification was created.
|
||||
|
||||
*changedBy*: Entity (immutable; server-set)
|
||||
Who made the change. An *Entity* object has the following
|
||||
properties:
|
||||
|
||||
*name*: String
|
||||
The name of the entity who made the change.
|
||||
*email*: String|null
|
||||
The email of the entity who made the change, or null if no
|
||||
email is available.
|
||||
*principalId*: Id|null
|
||||
The id of the Principal corresponding to the entity who made
|
||||
the change, or null if no associated Principal.
|
||||
|
||||
*objectType*: String (immutable; server-set)
|
||||
The name of the data type for the object whose permissions have
|
||||
changed, as registered in the IANA "JMAP Data Types" registry
|
||||
[IANA-JMAP], e.g., "Calendar" or "Mailbox".
|
||||
|
||||
*objectAccountId*: Id (immutable; server-set)
|
||||
The id of the Account where this object exists.
|
||||
|
||||
*objectId*: Id (immutable; server-set)
|
||||
The id of the object that this notification is about.
|
||||
|
||||
*oldRights*: String[Boolean]|null (immutable; server-set)
|
||||
The "myRights" property of the object for the user before the
|
||||
change.
|
||||
|
||||
*newRights*: String[Boolean]|null (immutable; server-set)
|
||||
The "myRights" property of the object for the user after the
|
||||
change.
|
||||
|
||||
*name*: String (immutable; server-set)
|
||||
The name of the object at the time the notification was made.
|
||||
Determining the name will depend on the data type in question.
|
||||
For example, it might be the "title" property of a CalendarEvent
|
||||
or the "name" of a Mailbox. The name is to show users who have
|
||||
had their access rights to the object removed what it is that they
|
||||
can no longer access.
|
||||
|
||||
3.1. ShareNotification/get
|
||||
|
||||
This is a standard "/get" method as described in [RFC8620],
|
||||
Section 5.1.
|
||||
|
||||
3.2. ShareNotification/changes
|
||||
|
||||
This is a standard "/changes" method as described in [RFC8620],
|
||||
Section 5.2.
|
||||
|
||||
3.3. ShareNotification/set
|
||||
|
||||
This is a standard "/set" method as described in [RFC8620],
|
||||
Section 5.3.
|
||||
|
||||
Only destroy is supported; any attempt to create/update MUST be
|
||||
rejected with a "forbidden" SetError.
|
||||
|
||||
3.4. ShareNotification/query
|
||||
|
||||
This is a standard "/query" method as described in [RFC8620],
|
||||
Section 5.5.
|
||||
|
||||
3.4.1. Filtering
|
||||
|
||||
A *FilterCondition* object has the following properties, all of which
|
||||
are optional:
|
||||
|
||||
*after*: UTCDate|null
|
||||
The creation date must be on or after this date to match the
|
||||
condition.
|
||||
|
||||
*before*: UTCDate|null
|
||||
The creation date must be before this date to match the condition.
|
||||
|
||||
*objectType*: String
|
||||
The objectType value must be identical to the given value to match
|
||||
the condition.
|
||||
|
||||
*objectAccountId*: Id
|
||||
The objectAccountId value must be identical to the given value to
|
||||
match the condition.
|
||||
|
||||
All given conditions in the FilterCondition object must match for the
|
||||
ShareNotification to match.
|
||||
|
||||
3.4.2. Sorting
|
||||
|
||||
The "created" property MUST be supported for sorting.
|
||||
|
||||
3.5. ShareNotification/queryChanges
|
||||
|
||||
This is a standard "/queryChanges" method as described in [RFC8620],
|
||||
Section 5.6.
|
||||
|
||||
4. Framework for Shared Data
|
||||
|
||||
Shareable data types MUST define the following three properties:
|
||||
|
||||
*isSubscribed*: Boolean
|
||||
The value true indicates that the user wishes to subscribe to see
|
||||
this data. The value false indicates that the user does not wish
|
||||
to subscribe to see this data. The initial value for this
|
||||
property when data is shared by another user is implementation
|
||||
dependent, although data types may give advice on appropriate
|
||||
defaults.
|
||||
|
||||
*myRights*: String[Boolean]
|
||||
The set of permissions the user currently has. Appropriate
|
||||
permissions are domain specific and must be defined per data type.
|
||||
Each key is the name of a permission defined for that data type.
|
||||
The value for the key is true if the user has the permission or
|
||||
false if they do not.
|
||||
|
||||
*shareWith*: Id[String[Boolean]]|null
|
||||
The value of this property is null if the data is not shared with
|
||||
anyone. Otherwise, it is a map where each key is the id of a
|
||||
Principal with which this data is shared, and the value associated
|
||||
with that key is the rights to give that Principal, in the same
|
||||
format as the "myRights" property. The Account id for the
|
||||
Principal id can be found in the capabilities of the Account this
|
||||
object is in (see Section 1.5.2).
|
||||
|
||||
Users with appropriate permission may set this property to modify
|
||||
who the data is shared with. The Principal that owns the Account
|
||||
that this data is in MUST NOT be in the map, since the owner's
|
||||
rights are implicit.
|
||||
|
||||
4.1. Example
|
||||
|
||||
Suppose we are designing a data model for a very simple to-do list.
|
||||
There is a Todo data type representing a single item to do, each of
|
||||
which belongs to a single TodoList. The specification makes the
|
||||
TodoLists shareable by referencing this document and defining the
|
||||
common properties.
|
||||
|
||||
First, it would define a set of domain-specific rights. For example,
|
||||
a TodoListRights object may have the following properties:
|
||||
|
||||
*mayRead*: Boolean
|
||||
The user may fetch this TodoList and any Todos that belong to this
|
||||
TodoList.
|
||||
|
||||
*mayWrite*: Boolean
|
||||
The user may create, update, or destroy Todos that belong to this
|
||||
TodoList and may change the "name" property of this TodoList.
|
||||
|
||||
*mayAdmin*: Boolean
|
||||
The user may see and modify the "myRights" property of this
|
||||
TodoList and may destroy this TodoList.
|
||||
|
||||
Then in the TodoList data type, we would include the three common
|
||||
properties described in Section 4, in addition to any type-specific
|
||||
properties (like "name" in this case):
|
||||
|
||||
*id*: Id (immutable; server-set)
|
||||
The id of the object.
|
||||
|
||||
*name*: String
|
||||
A name for this list of Todos.
|
||||
|
||||
*isSubscribed*: Boolean
|
||||
True if the user has indicated they wish to see this list. If
|
||||
false, clients should not display this TodoList with the user's
|
||||
other TodoLists but should provide a means for users to see and
|
||||
subscribe to all TodoLists that have been shared with them.
|
||||
|
||||
*myRights*: TodoListRights
|
||||
The set of permissions the user currently has for this TodoList.
|
||||
|
||||
*shareWith*: Id[TodoListRights]|null
|
||||
If not shared with anyone, the value is null. Otherwise, it's a
|
||||
map where the keys are Principal ids and the values are the rights
|
||||
given to those Principals. Users with the "mayAdmin" right may
|
||||
set this property to modify who the data is shared with. The
|
||||
Principal that owns the Account that this data is in MUST NOT be
|
||||
in the map; their rights are implicit.
|
||||
|
||||
We would also define a new Principal capability with two properties:
|
||||
|
||||
*accountId*: Id|null
|
||||
The accountId containing the Todo data for this Principal, if it
|
||||
has been shared with the requesting user.
|
||||
|
||||
*mayShareWith*: Boolean
|
||||
The user may give this Principal permission to access a TodoList.
|
||||
|
||||
A client wishing to let the user configure sharing would look at the
|
||||
"capabilities" for the Account containing the user's Todo data and
|
||||
find the "urn:ietf:params:jmap:principals:owner" property, as per
|
||||
Section 1.5.2. For example, the JMAP Session object might contain:
|
||||
|
||||
{
|
||||
"accounts": {
|
||||
"u12345678": {
|
||||
"name": "jane.doe@example.com",
|
||||
"isPersonal": true,
|
||||
"isReadOnly": false,
|
||||
"accountCapabilities": {
|
||||
"urn:com.example:jmap:todo": {},
|
||||
"urn:ietf:params:jmap:principals:owner": {
|
||||
"accountIdForPrincipal": "u33084183",
|
||||
"principalId": "P105aga511jaa"
|
||||
}
|
||||
}
|
||||
},
|
||||
...
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
Figure 1: Part of a JMAP Session Object
|
||||
|
||||
From this, the client now knows which Account has the Principal data,
|
||||
and it can fetch the list of Principals and offer to share it with
|
||||
the user by making an API request like this:
|
||||
|
||||
[[ "Principal/get", {
|
||||
"accountId": "u33084183",
|
||||
"ids": null
|
||||
}, "0" ]]
|
||||
|
||||
Figure 2: "methodCalls" Property of a JMAP Request
|
||||
|
||||
Here's an example response (where "Joe Bloggs" is another user that
|
||||
this user could share their TodoList with; Joe has not shared any of
|
||||
their own data with this user, so the "accounts" property is null):
|
||||
|
||||
[[ "Principal/get", {
|
||||
"accountId": "u33084183",
|
||||
"state": "7b8eff5zz",
|
||||
"list": [{
|
||||
"id": "P2342fnddd20",
|
||||
"type": "individual",
|
||||
"name": "Joe Bloggs",
|
||||
"description": null,
|
||||
"email": "joe.bloggs@example.com",
|
||||
"timeZone": "Australia/Melbourne",
|
||||
"capabilities": {
|
||||
"urn:com.example:jmap:todo": {
|
||||
"accountId": null,
|
||||
"mayShareWith": true
|
||||
}
|
||||
},
|
||||
"accounts": null
|
||||
}, {
|
||||
"id": "P674pp24095qo49pr",
|
||||
"name": "Board room",
|
||||
"type": "location",
|
||||
...
|
||||
}, ... ],
|
||||
"notFound": []
|
||||
}, "0" ]]
|
||||
|
||||
Figure 3: "methodResponses" Property of a JMAP Response
|
||||
|
||||
A TodoList can be shared with "Joe Bloggs" by updating its shareWith
|
||||
property, as in this example request:
|
||||
|
||||
[[ "TodoList/set", {
|
||||
"accountId": "u12345678",
|
||||
"update": {
|
||||
"tl01n231": {
|
||||
"shareWith": {
|
||||
"P2342fnddd20": {
|
||||
"mayRead": true,
|
||||
"mayWrite": true,
|
||||
"mayAdmin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "0" ]]
|
||||
|
||||
Figure 4: "methodCalls" Property of a JMAP Request
|
||||
|
||||
5. Internationalization Considerations
|
||||
|
||||
Experience has shown that unrestricted use of Unicode can lead to
|
||||
problems such as inconsistent rendering, users reading text and
|
||||
interpreting it differently than intended, and unexpected results
|
||||
when copying text from one location to another. Servers MAY choose
|
||||
to mitigate this by restricting the set of characters allowed in
|
||||
otherwise unconstrained String fields. The FreeformClass, as
|
||||
documented in [RFC8264], Section 4.3, might be a good starting point
|
||||
for this.
|
||||
|
||||
Attempts to set a value containing code points outside of the
|
||||
permissible set can be handled in a few ways by the server. The
|
||||
first option is to simply strip the forbidden characters and store
|
||||
the resulting string. This is likely to be appropriate for control
|
||||
characters, for example, where they can end up in data accidentally
|
||||
due to copy-and-paste issues and are probably invisible to the end
|
||||
user. JMAP allows the server to transform data on create/update, as
|
||||
long as any changed properties are returned to the client in the
|
||||
"/set" response so it knows what has changed, as per [RFC8620],
|
||||
Section 5.3. Alternatively, the server MAY just reject the create/
|
||||
update with an "invalidProperties" SetError.
|
||||
|
||||
6. Security Considerations
|
||||
|
||||
All security considerations of JMAP [RFC8620] apply to this
|
||||
specification. Additional considerations are detailed below.
|
||||
|
||||
6.1. Spoofing
|
||||
|
||||
Allowing users to edit their own Principal's name (and, to a lesser
|
||||
extent, email, description, or type) could allow a user to change
|
||||
their Principal to look like another user in the system, potentially
|
||||
tricking others into sharing private data with them. Servers may
|
||||
choose to forbid this and SHOULD keep logs of such changes to provide
|
||||
an audit trail.
|
||||
|
||||
Note that simply forbidding the use of a name already in the system
|
||||
is insufficient protection, as a malicious user could still change
|
||||
their name to something easily confused with the existing name by
|
||||
using trivial misspellings or visually similar Unicode characters.
|
||||
|
||||
6.2. Unnoticed Sharing
|
||||
|
||||
Sharing data with another user allows someone to turn a transitory
|
||||
account compromise (e.g., brief access to an unlocked or logged-in
|
||||
client) into a persistent compromise (by setting up sharing with a
|
||||
user that is controlled by the attacker). This can be mitigated by
|
||||
requiring further authorization for configuring sharing or sending
|
||||
notifications to the sharer via another channel whenever a new
|
||||
permission is added.
|
||||
|
||||
6.3. Denial of Service
|
||||
|
||||
By creating many changes to the sharing status of objects, a user can
|
||||
cause many ShareNotifications to be generated, which could lead to
|
||||
resource exhaustion. Servers can mitigate this by coalescing
|
||||
multiple changes to the same object into a single notification,
|
||||
limiting the maximum number of notifications it stores per user and/
|
||||
or rate-limiting the changes to sharing permissions in the first
|
||||
place. Automatically deleting older notifications after reaching a
|
||||
limit can mean the user is not made aware of a sharing change, which
|
||||
can itself be a security issue. For this reason, it is better to
|
||||
coalesce changes and use other mitigation strategies.
|
||||
|
||||
6.4. Unauthorized Principals
|
||||
|
||||
The set of Principals within a shared environment MUST be strictly
|
||||
controlled. If adding a new Principal is open to the public, risks
|
||||
include:
|
||||
|
||||
* An increased risk of a user accidentally sharing data with an
|
||||
unintended person.
|
||||
* An attacker sharing unwanted or offensive information with the
|
||||
user.
|
||||
* An attacker sharing items with spam content in the names in order
|
||||
to generate ShareNotification objects, which are likely to be
|
||||
prominently displayed to the user receiving them.
|
||||
|
||||
7. IANA Considerations
|
||||
|
||||
7.1. JMAP Capability Registration for "principals"
|
||||
|
||||
IANA has registered "principals" in the "JMAP Capabilities" registry
|
||||
as follows:
|
||||
|
||||
Capability Name: urn:ietf:params:jmap:principals
|
||||
Intended Use: common
|
||||
Change Controller: IETF
|
||||
Security and Privacy Considerations: RFC 9670, Section 6
|
||||
Reference: RFC 9670
|
||||
|
||||
7.2. JMAP Capability Registration for "principals:owner"
|
||||
|
||||
IANA has registered "principals:owner" in the "JMAP Capabilities"
|
||||
registry as follows:
|
||||
|
||||
Capability Name: urn:ietf:params:jmap:principals:owner
|
||||
Intended Use: common
|
||||
Change Controller: IETF
|
||||
Security and Privacy Considerations: RFC 9670, Section 6
|
||||
Reference: RFC 9670
|
||||
|
||||
7.3. JMAP Data Type Registration for "Principal"
|
||||
|
||||
IANA has registered "Principal" in the "JMAP Data Types" registry as
|
||||
follows:
|
||||
|
||||
Type Name: Principal
|
||||
Can Reference Blobs: No
|
||||
Can Use for State Change: Yes
|
||||
Capability: urn:ietf:params:jmap:principals
|
||||
Reference: RFC 9670
|
||||
|
||||
7.4. JMAP Data Type Registration for "ShareNotification"
|
||||
|
||||
IANA has registered "ShareNotification" in the "JMAP Data Types"
|
||||
registry as follows:
|
||||
|
||||
Type Name: ShareNotification
|
||||
Can Reference Blobs: No
|
||||
Can Use for State Change: Yes
|
||||
Capability: urn:ietf:params:jmap:principals
|
||||
Reference: RFC 9670
|
||||
|
||||
8. References
|
||||
|
||||
8.1. Normative References
|
||||
|
||||
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
|
||||
Requirement Levels", BCP 14, RFC 2119,
|
||||
DOI 10.17487/RFC2119, March 1997,
|
||||
<https://www.rfc-editor.org/info/rfc2119>.
|
||||
|
||||
[RFC5322] Resnick, P., Ed., "Internet Message Format", RFC 5322,
|
||||
DOI 10.17487/RFC5322, October 2008,
|
||||
<https://www.rfc-editor.org/info/rfc5322>.
|
||||
|
||||
[RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC
|
||||
2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174,
|
||||
May 2017, <https://www.rfc-editor.org/info/rfc8174>.
|
||||
|
||||
[RFC8620] Jenkins, N. and C. Newman, "The JSON Meta Application
|
||||
Protocol (JMAP)", RFC 8620, DOI 10.17487/RFC8620, July
|
||||
2019, <https://www.rfc-editor.org/info/rfc8620>.
|
||||
|
||||
8.2. Informative References
|
||||
|
||||
[IANA-JMAP]
|
||||
IANA, "JMAP Data Types",
|
||||
<https://www.iana.org/assignments/jmap>.
|
||||
|
||||
[IANA-TZDB]
|
||||
IANA, "Time Zone Database",
|
||||
<https://www.iana.org/time-zones>.
|
||||
|
||||
[RFC8264] Saint-Andre, P. and M. Blanchet, "PRECIS Framework:
|
||||
Preparation, Enforcement, and Comparison of
|
||||
Internationalized Strings in Application Protocols",
|
||||
RFC 8264, DOI 10.17487/RFC8264, October 2017,
|
||||
<https://www.rfc-editor.org/info/rfc8264>.
|
||||
|
||||
Author's Address
|
||||
|
||||
Neil Jenkins (editor)
|
||||
Fastmail
|
||||
PO Box 234, Collins St West
|
||||
Melbourne VIC 8007
|
||||
Australia
|
||||
Email: neilj@fastmailteam.com
|
||||
URI: https://www.fastmail.com
|
||||
@@ -1,844 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
Internet Engineering Task Force (IETF) N. Jenkins, Ed.
|
||||
Request for Comments: 9610 Fastmail
|
||||
Category: Standards Track December 2024
|
||||
ISSN: 2070-1721
|
||||
|
||||
|
||||
JSON Meta Application Protocol (JMAP) for Contacts
|
||||
|
||||
Abstract
|
||||
|
||||
This document specifies a data model for synchronising contact data
|
||||
with a server using the JSON Meta Application Protocol (JMAP).
|
||||
|
||||
Status of This Memo
|
||||
|
||||
This is an Internet Standards Track document.
|
||||
|
||||
This document is a product of the Internet Engineering Task Force
|
||||
(IETF). It represents the consensus of the IETF community. It has
|
||||
received public review and has been approved for publication by the
|
||||
Internet Engineering Steering Group (IESG). Further information on
|
||||
Internet Standards is available in Section 2 of RFC 7841.
|
||||
|
||||
Information about the current status of this document, any errata,
|
||||
and how to provide feedback on it may be obtained at
|
||||
https://www.rfc-editor.org/info/rfc9610.
|
||||
|
||||
Copyright Notice
|
||||
|
||||
Copyright (c) 2024 IETF Trust and the persons identified as the
|
||||
document authors. All rights reserved.
|
||||
|
||||
This document is subject to BCP 78 and the IETF Trust's Legal
|
||||
Provisions Relating to IETF Documents
|
||||
(https://trustee.ietf.org/license-info) in effect on the date of
|
||||
publication of this document. Please review these documents
|
||||
carefully, as they describe your rights and restrictions with respect
|
||||
to this document. Code Components extracted from this document must
|
||||
include Revised BSD License text as described in Section 4.e of the
|
||||
Trust Legal Provisions and are provided without warranty as described
|
||||
in the Revised BSD License.
|
||||
|
||||
Table of Contents
|
||||
|
||||
1. Introduction
|
||||
1.1. Notational Conventions
|
||||
1.2. Terminology
|
||||
1.3. Data Model Overview
|
||||
1.4. Addition to the Capabilities Object
|
||||
1.4.1. urn:ietf:params:jmap:contacts
|
||||
2. AddressBooks
|
||||
2.1. AddressBook/get
|
||||
2.2. AddressBook/changes
|
||||
2.3. AddressBook/set
|
||||
3. ContactCards
|
||||
3.1. ContactCard/get
|
||||
3.2. ContactCard/changes
|
||||
3.3. ContactCard/query
|
||||
3.3.1. Filtering
|
||||
3.3.2. Sorting
|
||||
3.4. ContactCard/queryChanges
|
||||
3.5. ContactCard/set
|
||||
3.6. ContactCard/copy
|
||||
4. Examples
|
||||
4.1. Fetching Initial Data
|
||||
4.2. Changing the Default Address Book
|
||||
5. Internationalisation Considerations
|
||||
6. Security Considerations
|
||||
7. IANA Considerations
|
||||
7.1. JMAP Capability Registration for "contacts"
|
||||
7.2. JMAP Data Type Registration for "AddressBook"
|
||||
7.3. JMAP Data Type Registration for "ContactCard"
|
||||
7.4. JMAP Error Codes Registry
|
||||
7.4.1. addressBookHasContents
|
||||
7.5. JSContact Property Registrations
|
||||
7.5.1. id
|
||||
7.5.2. addressBookIds
|
||||
7.5.3. blobId
|
||||
8. References
|
||||
8.1. Normative References
|
||||
8.2. Informative References
|
||||
Author's Address
|
||||
|
||||
1. Introduction
|
||||
|
||||
The JSON Meta Application Protocol (JMAP) [RFC8620] is a generic
|
||||
protocol for synchronising data, such as mail, calendars, or
|
||||
contacts, between a client and a server. It is optimised for mobile
|
||||
and web environments and aims to provide a consistent interface to
|
||||
different data types.
|
||||
|
||||
This specification defines a data model for synchronising contacts
|
||||
between a client and a server using JMAP.
|
||||
|
||||
1.1. Notational Conventions
|
||||
|
||||
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
|
||||
"SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and
|
||||
"OPTIONAL" in this document are to be interpreted as described in
|
||||
BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all
|
||||
capitals, as shown here.
|
||||
|
||||
Type signatures, examples, and property descriptions in this document
|
||||
follow the conventions established in Section 1.1 of [RFC8620]. The
|
||||
Id, UnsignedInt, and UTCDate data types defined in Sections 1.2, 1.3,
|
||||
and 1.4 of [RFC8620] are also used in this document.
|
||||
|
||||
1.2. Terminology
|
||||
|
||||
The same terminology used in the core JMAP specification (see
|
||||
Section 1.6 of [RFC8620]) is also used in this document.
|
||||
|
||||
The terms AddressBook and ContactCard (with these specific
|
||||
capitalizations) are used to refer to the data types defined in this
|
||||
document and instances of those data types.
|
||||
|
||||
1.3. Data Model Overview
|
||||
|
||||
An Account (see Section 1.6.2 of [RFC8620]) with support for the
|
||||
contact data model contains zero or more AddressBook objects, which
|
||||
is a named collection of zero or more ContactCards. A ContactCard is
|
||||
a representation of a person, company, entity, or a group of such
|
||||
entities in JSContact Card format, as defined in Section 2 of
|
||||
[RFC9553]. Each ContactCard belongs to one or more AddressBooks.
|
||||
|
||||
In servers with support for JMAP Sharing [RFC9670], users may see and
|
||||
configure sharing of contact data with others. Sharing permissions
|
||||
are managed per AddressBook.
|
||||
|
||||
1.4. Addition to the Capabilities Object
|
||||
|
||||
The capabilities object is returned as part of the JMAP Session
|
||||
object; see Section 2 of [RFC8620]. This document defines one
|
||||
additional capability URI.
|
||||
|
||||
1.4.1. urn:ietf:params:jmap:contacts
|
||||
|
||||
This represents support for the AddressBook and ContactCard data
|
||||
types and associated API methods. The value of this property in the
|
||||
JMAP Session "capabilities" property is an empty object.
|
||||
|
||||
The value of this property in an account's "accountCapabilities"
|
||||
property is an object that MUST contain the following information on
|
||||
server capabilities and permissions for that account:
|
||||
|
||||
*maxAddressBooksPerCard*: UnsignedInt|null
|
||||
The maximum number of AddressBooks (see Section 2) that can be
|
||||
assigned to a single ContactCard object (see Section 3). This
|
||||
MUST be an integer >= 1, or null for no limit (or rather, the
|
||||
limit is always the number of AddressBooks in the account).
|
||||
|
||||
*mayCreateAddressBook*: Boolean
|
||||
The user may create an AddressBook in this account if, and only
|
||||
if, this is true.
|
||||
|
||||
2. AddressBooks
|
||||
|
||||
An AddressBook is a named collection of ContactCards. All
|
||||
ContactCards are associated with one or more AddressBooks.
|
||||
|
||||
An *AddressBook* object has the following properties:
|
||||
|
||||
*id*: Id (immutable; server-set)
|
||||
The id of the AddressBook.
|
||||
|
||||
*name*: String
|
||||
The user-visible name of the AddressBook. This MUST NOT be the
|
||||
empty string and MUST NOT be greater than 255 octets in size when
|
||||
encoded as UTF-8.
|
||||
|
||||
*description*: String|null (default: null)
|
||||
An optional long-form description of the AddressBook that provides
|
||||
context in shared environments where users need more than just the
|
||||
name.
|
||||
|
||||
*sortOrder*: UnsignedInt (default: 0)
|
||||
Defines the sort order of AddressBooks when presented in the
|
||||
client's UI so it is consistent between devices. The number MUST
|
||||
be an integer in the range 0 <= sortOrder < 2^31.
|
||||
|
||||
An AddressBook with a lower order is to be displayed before a
|
||||
AddressBook with a higher order in any list of AddressBooks in the
|
||||
client's UI. AddressBooks with equal order should be sorted in
|
||||
alphabetical order by name. The sorting should take into account
|
||||
locale-specific character order convention.
|
||||
|
||||
*isDefault*: Boolean (server-set)
|
||||
This SHOULD be true for exactly one AddressBook in any account and
|
||||
MUST NOT be true for more than one AddressBook within an account.
|
||||
The default AddressBook should be used by clients whenever they
|
||||
need to choose an AddressBook for the user within this account and
|
||||
they do not have any other information on which to make a choice.
|
||||
For example, if the user creates a new contact card, the client
|
||||
may automatically set the card as belonging to the default
|
||||
AddressBook from the user's primary account.
|
||||
|
||||
*isSubscribed*: Boolean
|
||||
True if the user has indicated they wish to see this AddressBook
|
||||
in their client. This SHOULD default to false for AddressBooks in
|
||||
shared accounts that the user has access to and true for any new
|
||||
AddressBooks created by the user themself.
|
||||
|
||||
If false, the AddressBook and its contents SHOULD only be
|
||||
displayed when the user explicitly requests it. The UI may offer
|
||||
to the user the option of subscribing to it.
|
||||
|
||||
*shareWith*: Id[AddressBookRights]|null (default: null)
|
||||
A map of the Principal id (Section 2 of [RFC9670]) to rights for
|
||||
Principals this AddressBook is shared with. The Principal to
|
||||
which this AddressBook belongs MUST NOT be in this set. This is
|
||||
null if the AddressBook is not shared with anyone or if the server
|
||||
does not support [RFC9670]. The value may be modified only if the
|
||||
user has the "mayShare" right. The account id for the Principals
|
||||
may be found in the urn:ietf:params:jmap:principals:owner
|
||||
capability of the Account to which the AddressBook belongs.
|
||||
|
||||
*myRights*: AddressBookRights (server-set)
|
||||
The set of access rights the user has in relation to this
|
||||
AddressBook.
|
||||
|
||||
An *AddressBookRights* object has the following properties:
|
||||
|
||||
*mayRead*: Boolean
|
||||
The user may fetch the ContactCards in this AddressBook.
|
||||
|
||||
*mayWrite*: Boolean
|
||||
The user may create, modify, or destroy all ContactCards in this
|
||||
AddressBook, or move them to or from this AddressBook.
|
||||
|
||||
*mayShare*: Boolean
|
||||
The user may modify the "shareWith" property for this AddressBook.
|
||||
|
||||
*mayDelete*: Boolean
|
||||
The user may delete the AddressBook itself.
|
||||
|
||||
2.1. AddressBook/get
|
||||
|
||||
This is a standard "/get" method as described in Section 5.1 of
|
||||
[RFC8620]. The "ids" argument may be null to fetch all at once.
|
||||
|
||||
2.2. AddressBook/changes
|
||||
|
||||
This is a standard "/changes" method as described in Section 5.2 of
|
||||
[RFC8620].
|
||||
|
||||
2.3. AddressBook/set
|
||||
|
||||
This is a standard "/set" method as described in Section 5.3 of
|
||||
[RFC8620], but with the following additional request arguments:
|
||||
|
||||
*onDestroyRemoveContents*: Boolean (default: false)
|
||||
If false, any attempt to destroy an AddressBook that still has a
|
||||
ContactCard in it will be rejected with an
|
||||
"addressBookHasContents" SetError. If true, any ContactCard that
|
||||
is in the AddressBook will be removed from it, and if such a
|
||||
ContactCard does not belong to any other AddressBook, it will be
|
||||
destroyed.
|
||||
|
||||
*onSuccessSetIsDefault*: Id|null
|
||||
If an id is given, and all creates, updates, and destroys (if any)
|
||||
succeed without error, the server will try to set this AddressBook
|
||||
as the default. (For references to AddressBook creations, this is
|
||||
equivalent to a creation-reference, so the id will be the creation
|
||||
id prefixed with a "#".)
|
||||
|
||||
If the id is not found or if the change is not permitted by the
|
||||
server for policy reasons, it MUST be ignored and the current default
|
||||
AddressBook (if any) will remain as such. No error is returned to
|
||||
the client in this case.
|
||||
|
||||
As per Section 5.3 of [RFC8620], if the default AddressBook is
|
||||
successfully changed, any changed objects MUST be reported in either
|
||||
the "created" or "updated" argument in the response as appropriate,
|
||||
with the server-set value included.
|
||||
|
||||
The "shareWith" property may only be set by users that have the
|
||||
"mayShare" right. When modifying the "shareWith" property, the user
|
||||
cannot give a right to a Principal if the Principal did not already
|
||||
have that right and the user making the change also does not have
|
||||
that right. Any attempt to do so MUST be rejected with a "forbidden"
|
||||
SetError.
|
||||
|
||||
Users can subscribe or unsubscribe to an AddressBook by setting the
|
||||
"isSubscribed" property. The server MAY forbid users from
|
||||
subscribing to certain AddressBooks even though they have permission
|
||||
to see them, rejecting the update with a "forbidden" SetError.
|
||||
|
||||
The following extra SetError type is defined for "destroy":
|
||||
|
||||
*addressBookHasContents*: The AddressBook has at least one
|
||||
ContactCard assigned to it and the "onDestroyRemoveContents"
|
||||
argument was false.
|
||||
|
||||
3. ContactCards
|
||||
|
||||
A *ContactCard* object contains information about a person, company,
|
||||
or other entity, or represents a group of such entities. It is a
|
||||
JSContact Card object as defined in Section 2 of [RFC9553] with the
|
||||
following additional properties:
|
||||
|
||||
*id*: Id (immutable; server-set)
|
||||
The id of the ContactCard. The "id" property MAY be different to
|
||||
the ContactCard's "uid" property (as defined in Section 2.1.9 of
|
||||
[RFC9553]). However, there MUST NOT be more than one ContactCard
|
||||
with the same uid in an Account.
|
||||
|
||||
*addressBookIds*: Id[Boolean]
|
||||
The set of AddressBook ids that this ContactCard belongs to. A
|
||||
card MUST belong to at least one AddressBook at all times (until
|
||||
it is destroyed). The set is represented as an object, with each
|
||||
key being an AddressBook id. The value for each key in the object
|
||||
MUST be true.
|
||||
|
||||
For any Media object in the card (see Section 2.6.4 of [RFC9553]), a
|
||||
new property is defined:
|
||||
|
||||
*blobId*: Id
|
||||
An id for the Blob representing the binary contents of the
|
||||
resource.
|
||||
|
||||
When returning ContactCards, any Media with a URI that uses the
|
||||
"data:" URL scheme [RFC2397] SHOULD return a "blobId" property and
|
||||
omit the "uri" property, as this lets clients load the (potentially
|
||||
large) image file only when needed and avoids the overhead of Base64
|
||||
encoding. The "mediaType" property MUST also be set. Similarly,
|
||||
when creating or updating a ContactCard, clients MAY send a "blobId"
|
||||
instead of the "uri" property for a Media object.
|
||||
|
||||
A contact card with a "kind" property equal to "group" represents a
|
||||
group of contacts. Clients often present these separately from other
|
||||
contact cards. The "members" property, as defined in Section 2.1.6
|
||||
of [RFC9553], contains a set of uids (as defined in Section 2.1.9 of
|
||||
[RFC9553]) for other contacts that are the members of this group.
|
||||
Clients should consider the group to contain any ContactCard with a
|
||||
matching uid from any account they have access to that has support
|
||||
for the urn:ietf:params:jmap:contacts capability. Any uid that
|
||||
cannot be found SHOULD be ignored but preserved. For example,
|
||||
suppose a user adds contacts from a shared address book to their
|
||||
private group, then temporarily loses access to this address book.
|
||||
The uids cannot be resolved, so the contacts will disappear from the
|
||||
group. However, if they are given permission to access the data
|
||||
again, the uids will be found and the contacts will reappear.
|
||||
|
||||
3.1. ContactCard/get
|
||||
|
||||
This is a standard "/get" method as described in Section 5.1 of
|
||||
[RFC8620].
|
||||
|
||||
3.2. ContactCard/changes
|
||||
|
||||
This is a standard "/changes" method as described in Section 5.2 of
|
||||
[RFC8620].
|
||||
|
||||
3.3. ContactCard/query
|
||||
|
||||
This is a standard "/query" method as described in Section 5.5 of
|
||||
[RFC8620].
|
||||
|
||||
3.3.1. Filtering
|
||||
|
||||
A *FilterCondition* object has the following properties, any of which
|
||||
may be omitted:
|
||||
|
||||
*inAddressBook*: Id
|
||||
An AddressBook id. A card must be in this address book to match
|
||||
the condition.
|
||||
|
||||
*uid*: String
|
||||
A card must have this string exactly as its uid (as defined in
|
||||
Section 2.1.9 of [RFC9553]) to match.
|
||||
|
||||
*hasMember*: String
|
||||
A card must have a "members" property (as defined in Section 2.1.6
|
||||
of [RFC9553]) that contains this string as one of the uids in the
|
||||
set to match.
|
||||
|
||||
*kind*: String
|
||||
A card must have a "kind" property (as defined in Section 2.1.4 of
|
||||
[RFC9553]) that equals this string exactly to match.
|
||||
|
||||
*createdBefore*: UTCDate
|
||||
The "created" date-time of the ContactCard (as defined in
|
||||
Section 2.1.3 of [RFC9553]) must be before this date-time to match
|
||||
the condition.
|
||||
|
||||
*createdAfter*: UTCDate
|
||||
The "created" date-time of the ContactCard (as defined in
|
||||
Section 2.1.3 of [RFC9553]) must be the same or after this date-
|
||||
time to match the condition.
|
||||
|
||||
*updatedBefore*: UTCDate
|
||||
The "updated" date-time of the ContactCard (as defined in
|
||||
Section 2.1.10 of [RFC9553]) must be before this date-time to
|
||||
match the condition.
|
||||
|
||||
*updatedAfter*: UTCDate
|
||||
The "updated" date-time of the ContactCard (as defined in
|
||||
Section 2.1.10 of [RFC9553]) must be the same or after this date-
|
||||
time to match the condition.
|
||||
|
||||
*text*: String
|
||||
A card matches this condition if the text matches with text in the
|
||||
card.
|
||||
|
||||
*name*: String
|
||||
A card matches this condition if the value of any NameComponent in
|
||||
the "name" property or the "full" property in the "name" property
|
||||
of the card (as defined in Section 2.2.1.2 of [RFC9553]) matches
|
||||
the value.
|
||||
|
||||
*name/given*: String
|
||||
A card matches this condition if the value of a NameComponent with
|
||||
kind "given" inside the "name" property of the card (as defined in
|
||||
Section 2.2.1.2 of [RFC9553]) matches the value.
|
||||
|
||||
*name/surname*: String
|
||||
A card matches this condition if the value of a NameComponent with
|
||||
kind "surname" inside the "name" property of the card (as defined
|
||||
in Section 2.2.1.2 of [RFC9553]) matches the value.
|
||||
|
||||
*name/surname2*: String
|
||||
A card matches this condition if the value of a NameComponent with
|
||||
kind "surname2" inside the "name" property of the card (as defined
|
||||
in Section 2.2.1.2 of [RFC9553]) matches the value.
|
||||
|
||||
*nickname*: String
|
||||
A card matches this condition if the "name" of any Nickname in the
|
||||
"nicknames" property of the card (as defined in Section 2.2.2 of
|
||||
[RFC9553]) matches the value.
|
||||
|
||||
*organization*: String
|
||||
A card matches this condition if the "name" of any Organization in
|
||||
the "organizations" property of the card (as defined in
|
||||
Section 2.2.3 of [RFC9553]) matches the value.
|
||||
|
||||
*email*: String
|
||||
A card matches this condition if the "address" or "label" of any
|
||||
EmailAddress in the "emails" property of the card (as defined in
|
||||
Section 2.3.1 of [RFC9553]) matches the value.
|
||||
|
||||
*phone*: String
|
||||
A card matches this condition if the "number" or "label" of any
|
||||
Phone in the "phones" property of the card (as defined in
|
||||
Section 2.3.3 of [RFC9553]) matches the value.
|
||||
|
||||
*onlineService*: String
|
||||
A card matches this condition if the "service", "uri", "user", or
|
||||
"label" of any OnlineService in the "onlineServices" property of
|
||||
the card (as defined in Section 2.3.2 of [RFC9553]) matches the
|
||||
value.
|
||||
|
||||
*address*: String
|
||||
A card matches this condition if the value of any AddressComponent
|
||||
in the "addresses" property or the "full" property in the
|
||||
"addresses" property of the card (as defined in Section 2.5.1 of
|
||||
[RFC9553]) matches the value.
|
||||
|
||||
*note*: String
|
||||
A card matches this condition if the "note" of any Note in the
|
||||
"notes" property of the card (as defined in Section 2.8.3 of
|
||||
[RFC9553]) matches the value.
|
||||
|
||||
If zero properties are specified on the FilterCondition, the
|
||||
condition MUST always evaluate to true. If multiple properties are
|
||||
specified, ALL must apply for the condition to be true (it is
|
||||
equivalent to splitting the object into one-property conditions and
|
||||
making them all the child of an AND filter operator).
|
||||
|
||||
The exact semantics for matching String fields is deliberately not
|
||||
defined to allow for flexibility in indexing implementation, subject
|
||||
to the following:
|
||||
|
||||
* Text SHOULD be matched in a case-insensitive manner.
|
||||
|
||||
* Text contained in either (but matched) single or double quotes
|
||||
SHOULD be treated as a phrase search. That is, a match is
|
||||
required for that exact sequence of words, excluding the
|
||||
surrounding quotation marks. Use \", \', and \\ to match a
|
||||
literal ", ', and \ respectively in a phrase.
|
||||
|
||||
* Outside of a phrase, whitespace SHOULD be treated as dividing
|
||||
separate tokens that may be searched for separately in the
|
||||
contact, but MUST all be present for the contact to match the
|
||||
filter.
|
||||
|
||||
* Tokens MAY be matched on a whole-word basis using stemming (e.g.,
|
||||
a text search for bus would match "buses", but not "business").
|
||||
|
||||
3.3.2. Sorting
|
||||
|
||||
The following values for the "property" field on the Comparator
|
||||
object MUST be supported for sorting:
|
||||
|
||||
* "created" - The "created" date on the ContactCard.
|
||||
|
||||
* "updated" - The "updated" date on the ContactCard.
|
||||
|
||||
The following values for the "property" field on the Comparator
|
||||
object SHOULD be supported for sorting:
|
||||
|
||||
* "name/given" - The value of the first NameComponent in the "name"
|
||||
property whose "kind" is "given".
|
||||
|
||||
* "name/surname" - The value of the first NameComponent in the
|
||||
"name" property whose "kind" is "surname".
|
||||
|
||||
* "name/surname2" - The value of the first NameComponent in the
|
||||
"name" property whose "kind" is "surname2".
|
||||
|
||||
3.4. ContactCard/queryChanges
|
||||
|
||||
This is a standard "/queryChanges" method as described in Section 5.6
|
||||
of [RFC8620].
|
||||
|
||||
3.5. ContactCard/set
|
||||
|
||||
This is a standard "/set" method as described in Section 5.3 of
|
||||
[RFC8620].
|
||||
|
||||
To set a new photo, the file must first be uploaded using the upload
|
||||
mechanism as described in Section 6.1 of [RFC8620]. This will give
|
||||
the client a valid blobId, size, and type to use. The server MUST
|
||||
reject attempts to set a file that is not a recognised image type as
|
||||
the photo for a card.
|
||||
|
||||
3.6. ContactCard/copy
|
||||
|
||||
This is a standard "/copy" method as described in Section 5.4 of
|
||||
[RFC8620].
|
||||
|
||||
4. Examples
|
||||
|
||||
For brevity, only the "methodCalls" property of the Request object
|
||||
and the "methodResponses" property of the Response object is shown in
|
||||
the following examples.
|
||||
|
||||
4.1. Fetching Initial Data
|
||||
|
||||
A user has authenticated and the client has fetched the JMAP Session
|
||||
object. It finds a single Account with the
|
||||
"urn:ietf:params:jmap:contacts" capability with id "a0x9" and wants
|
||||
to fetch all the address books and contacts. It might make the
|
||||
following request:
|
||||
|
||||
[
|
||||
["AddressBook/get", {
|
||||
"accountId": "a0x9"
|
||||
}, "0"],
|
||||
["ContactCard/get", {
|
||||
"accountId": "a0x9"
|
||||
}, "1"]
|
||||
]
|
||||
|
||||
Figure 1: "methodCalls" Property of a JMAP Request
|
||||
|
||||
The server might respond with something like:
|
||||
|
||||
[
|
||||
["AddressBook/get", {
|
||||
"accountId": "a0x9",
|
||||
"list": [{
|
||||
"id": "062adcfa-105d-455c-bc60-6db68b69c3f3",
|
||||
"name": "Personal",
|
||||
"description": null,
|
||||
"sortOrder": 0,
|
||||
"isDefault": true,
|
||||
"isSubscribed": true,
|
||||
"shareWith": {
|
||||
"3f1502e0-63fe-4335-9ff3-e739c188f5dd": {
|
||||
"mayRead": true,
|
||||
"mayWrite": false,
|
||||
"mayShare": false,
|
||||
"mayDelete": false
|
||||
}
|
||||
},
|
||||
"myRights": {
|
||||
"mayRead": true,
|
||||
"mayWrite": true,
|
||||
"mayShare": true,
|
||||
"mayDelete": false
|
||||
}
|
||||
}, {
|
||||
"id": "cd40089d-35f9-4fd7-980b-ba3a9f1d74fe",
|
||||
"name": "Autosaved",
|
||||
"description": null,
|
||||
"sortOrder": 1,
|
||||
"isDefault": false,
|
||||
"isSubscribed": true,
|
||||
"shareWith": null,
|
||||
"myRights": {
|
||||
"mayRead": true,
|
||||
"mayWrite": true,
|
||||
"mayShare": true,
|
||||
"mayDelete": false
|
||||
}
|
||||
}],
|
||||
"notFound": [],
|
||||
"state": "~4144"
|
||||
}, "0"],
|
||||
["ContactCard/get", {
|
||||
"accountId": "a0x9",
|
||||
"list": [{
|
||||
"id": "3",
|
||||
"addressBookIds": {
|
||||
"062adcfa-105d-455c-bc60-6db68b69c3f3": true
|
||||
},
|
||||
"name": {
|
||||
"components": [
|
||||
{ "kind": "given", "value": "Joe" },
|
||||
{ "kind": "surname", "value": "Bloggs" }
|
||||
],
|
||||
"isOrdered": true
|
||||
},
|
||||
"emails": {
|
||||
"0": {
|
||||
"contexts": {
|
||||
"private": true
|
||||
},
|
||||
"address": "joe.bloggs@example.com"
|
||||
}
|
||||
}
|
||||
}],
|
||||
"notFound": [],
|
||||
"state": "ewarbckaqJ::112"
|
||||
}, "1"]
|
||||
]
|
||||
|
||||
Figure 2: "methodResponses" Property of a JMAP Response
|
||||
|
||||
4.2. Changing the Default Address Book
|
||||
|
||||
The client tries to change the default address book from "Personal"
|
||||
to "Autosaved" (and makes no other change):
|
||||
|
||||
[
|
||||
["AddressBook/set", {
|
||||
"accountId": "a0x9",
|
||||
"onSuccessSetIsDefault": "cd40089d-35f9-4fd7-980b-ba3a9f1d74fe"
|
||||
}, "0"]
|
||||
]
|
||||
|
||||
Figure 3: "methodCalls" Property of a JMAP Request
|
||||
|
||||
The server allows the change, returning the following response:
|
||||
|
||||
[
|
||||
["AddressBook/set", {
|
||||
"accountId": "a0x9",
|
||||
"updated": {
|
||||
"cd40089d-35f9-4fd7-980b-ba3a9f1d74fe": {
|
||||
"isDefault": true
|
||||
},
|
||||
"062adcfa-105d-455c-bc60-6db68b69c3f3": {
|
||||
"isDefault": false
|
||||
},
|
||||
"oldState": "~4144",
|
||||
"newState": "~4148"
|
||||
}
|
||||
}, "0"]
|
||||
]
|
||||
|
||||
Figure 4: "methodResponses" Property of a JMAP Response
|
||||
|
||||
5. Internationalisation Considerations
|
||||
|
||||
Experience has shown that unrestricted use of Unicode can lead to
|
||||
problems such as inconsistent rendering, users reading text and
|
||||
interpreting it differently than intended, and unexpected results
|
||||
when copying text from one location to another. Servers MAY choose
|
||||
to mitigate this by restricting the set of characters allowed in
|
||||
otherwise unconstrained String fields. The FreeformClass, as
|
||||
documented in Section 4.3 of [RFC8264], might be a good starting
|
||||
point for this.
|
||||
|
||||
Attempts to set a value containing code points outside of the
|
||||
permissible set can be handled in a few ways by the server. The
|
||||
server could choose to strip the forbidden characters or replace them
|
||||
with U+FFFD (the Unicode replacement character) and store the
|
||||
resulting string. This is likely to be appropriate for non-printable
|
||||
characters -- such as the "Control Codes" defined in Section 23.1
|
||||
(https://www.unicode.org/versions/latest/core-spec/chapter-
|
||||
23/#G20365) of [UNICODE], excluding newline (U+000A), carriage return
|
||||
(U+000D), and tab (U+0009) -- that can end up in data accidentally
|
||||
due to copy-and-paste issues but are invisible to the end user. JMAP
|
||||
allows the server to transform data on create/update as long as any
|
||||
changed properties are returned to the client in the "/set" response
|
||||
so it knows what has changed, as per Section 5.3 of [RFC8620].
|
||||
Alternatively, the server MAY just reject the create/update with an
|
||||
"invalidProperties" SetError.
|
||||
|
||||
6. Security Considerations
|
||||
|
||||
All security considerations of JMAP [RFC8620] apply to this
|
||||
specification. Additional considerations specific to the data types
|
||||
and functionality introduced by this document are described in the
|
||||
following subsection.
|
||||
|
||||
Contacts consist almost entirely of private, personally identifiable
|
||||
information, and represent the social connections of users. Privacy
|
||||
leaks can have real world consequences, and contact servers and
|
||||
clients MUST be mindful of the need to keep all data secure.
|
||||
|
||||
Servers MUST enforce the Access Control Lists (ACLs) set on address
|
||||
books to ensure only authorised data is shared.
|
||||
|
||||
7. IANA Considerations
|
||||
|
||||
7.1. JMAP Capability Registration for "contacts"
|
||||
|
||||
IANA has registered "contacts" in the "JMAP Capabilities" registry as
|
||||
follows:
|
||||
|
||||
Capability Name: urn:ietf:params:jmap:contacts
|
||||
Intended Use: common
|
||||
Change Controller: IETF
|
||||
Security and Privacy Considerations: this document, Section 6
|
||||
Reference: this document
|
||||
|
||||
7.2. JMAP Data Type Registration for "AddressBook"
|
||||
|
||||
IANA has registered "AddressBook" in the "JMAP Data Types" registry
|
||||
as follows:
|
||||
|
||||
Type Name: AddressBook
|
||||
Can Reference Blobs: No
|
||||
Can Use for State Change: Yes
|
||||
Capability: urn:ietf:params:jmap:contacts
|
||||
Reference: this document
|
||||
|
||||
7.3. JMAP Data Type Registration for "ContactCard"
|
||||
|
||||
IANA has registered "ContactCard" in the "JMAP Data Types" registry
|
||||
as follows:
|
||||
|
||||
Type Name: ContactCard
|
||||
Can Reference Blobs: Yes
|
||||
Can Use for State Change: Yes
|
||||
Capability: urn:ietf:params:jmap:contacts
|
||||
Reference: this document
|
||||
|
||||
7.4. JMAP Error Codes Registry
|
||||
|
||||
The following subsection has registered a new error code in the "JMAP
|
||||
Error Codes" registry, as defined in Section 9 of [RFC8620].
|
||||
|
||||
7.4.1. addressBookHasContents
|
||||
|
||||
JMAP Error Code: addressBookHasContents
|
||||
Intended Use: common
|
||||
Change Controller: IETF
|
||||
Description: The AddressBook has at least one ContactCard assigned
|
||||
to it, and the "onDestroyRemoveContents" argument was false.
|
||||
Reference: This document, Section 2.3
|
||||
|
||||
7.5. JSContact Property Registrations
|
||||
|
||||
IANA has registered the following additional properties in the
|
||||
"JSContact Properties" registry, as defined in Section 3 of
|
||||
[RFC9553].
|
||||
|
||||
7.5.1. id
|
||||
|
||||
Property Name: id
|
||||
Property Type: not applicable
|
||||
Property Context: Card
|
||||
Intended Usage: reserved
|
||||
Since Version: 1.0
|
||||
Change Controller: IETF
|
||||
Reference: this document
|
||||
|
||||
7.5.2. addressBookIds
|
||||
|
||||
Property Name: addressBookIds
|
||||
Property Type: not applicable
|
||||
Property Context: Card
|
||||
Intended Usage: reserved
|
||||
Since Version: 1.0
|
||||
Change Controller: IETF
|
||||
Reference: this document
|
||||
|
||||
7.5.3. blobId
|
||||
|
||||
Property Name: blobId
|
||||
Property Type: not applicable
|
||||
Property Context: Media
|
||||
Intended Usage: reserved
|
||||
Since Version: 1.0
|
||||
Change Controller: IETF
|
||||
Reference: this document
|
||||
|
||||
8. References
|
||||
|
||||
8.1. Normative References
|
||||
|
||||
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
|
||||
Requirement Levels", BCP 14, RFC 2119,
|
||||
DOI 10.17487/RFC2119, March 1997,
|
||||
<https://www.rfc-editor.org/info/rfc2119>.
|
||||
|
||||
[RFC2397] Masinter, L., "The "data" URL scheme", RFC 2397,
|
||||
DOI 10.17487/RFC2397, August 1998,
|
||||
<https://www.rfc-editor.org/info/rfc2397>.
|
||||
|
||||
[RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC
|
||||
2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174,
|
||||
May 2017, <https://www.rfc-editor.org/info/rfc8174>.
|
||||
|
||||
[RFC8620] Jenkins, N. and C. Newman, "The JSON Meta Application
|
||||
Protocol (JMAP)", RFC 8620, DOI 10.17487/RFC8620, July
|
||||
2019, <https://www.rfc-editor.org/info/rfc8620>.
|
||||
|
||||
[RFC9553] Stepanek, R. and M. Loffredo, "JSContact: A JSON
|
||||
Representation of Contact Data", RFC 9553,
|
||||
DOI 10.17487/RFC9553, May 2024,
|
||||
<https://www.rfc-editor.org/info/rfc9553>.
|
||||
|
||||
[RFC9670] Jenkins, N., Ed., "JSON Meta Application Protocol (JMAP)
|
||||
Sharing", RFC 9670, DOI 10.17487/RFC9670, November 2024,
|
||||
<https://www.rfc-editor.org/info/rfc9670>.
|
||||
|
||||
8.2. Informative References
|
||||
|
||||
[RFC8264] Saint-Andre, P. and M. Blanchet, "PRECIS Framework:
|
||||
Preparation, Enforcement, and Comparison of
|
||||
Internationalized Strings in Application Protocols",
|
||||
RFC 8264, DOI 10.17487/RFC8264, October 2017,
|
||||
<https://www.rfc-editor.org/info/rfc8264>.
|
||||
|
||||
[UNICODE] The Unicode Consortium, "The Unicode Standard",
|
||||
<https://www.unicode.org/versions/latest/>.
|
||||
|
||||
Author's Address
|
||||
|
||||
Neil Jenkins (editor)
|
||||
Fastmail
|
||||
PO Box 234, Collins St West
|
||||
Melbourne VIC 8007
|
||||
Australia
|
||||
Email: neilj@fastmailteam.com
|
||||
URI: https://www.fastmail.com
|
||||
@@ -1,843 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Network Working Group G. Neufeld
|
||||
Request for Comments: 2369 Nisto
|
||||
Category: Standards Track J. Baer
|
||||
SkyWeyr Technologies
|
||||
July 1998
|
||||
|
||||
|
||||
The Use of URLs as Meta-Syntax for Core Mail List Commands
|
||||
and their Transport through Message Header Fields
|
||||
|
||||
Status of this Memo
|
||||
|
||||
This document specifies an Internet standards track protocol for the
|
||||
Internet community, and requests discussion and suggestions for
|
||||
improvements. Please refer to the current edition of the "Internet
|
||||
Official Protocol Standards" (STD 1) for the standardization state
|
||||
and status of this protocol. Distribution of this memo is unlimited.
|
||||
|
||||
Copyright Notice
|
||||
|
||||
Copyright (C) The Internet Society (1998). All Rights Reserved.
|
||||
|
||||
Abstract
|
||||
|
||||
The mailing list command specification header fields are a set of
|
||||
structured fields to be added to email messages sent by email
|
||||
distribution lists. Each field typically contains a URL (usually
|
||||
mailto [RFC2368]) locating the relevant information or performing the
|
||||
command directly. The three core header fields described in this
|
||||
document are List-Help, List-Subscribe, and List-Unsubscribe.
|
||||
|
||||
There are three other header fields described here which, although
|
||||
not as widely applicable, will have utility for a sufficient number
|
||||
of mailing lists to justify their formalization here. These are
|
||||
List-Post, List-Owner and List-Archive.
|
||||
|
||||
By including these header fields, list servers can make it possible
|
||||
for mail clients to provide automated tools for users to perform list
|
||||
functions. This could take the form of a menu item, push button, or
|
||||
other user interface element. The intent is to simplify the user
|
||||
experience, providing a common interface to the often cryptic and
|
||||
varied mailing list manager commands.
|
||||
|
||||
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
|
||||
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
|
||||
document are to be interpreted as described in RFC 2119.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 1]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
1. Introduction
|
||||
|
||||
This is a proposal for additional header fields to be added to email
|
||||
messages sent by email distribution lists. The content of each new
|
||||
field is typically a URL - usually mailto [RFC2368] - which locates
|
||||
the relevant information or performs the command directly. MTAs
|
||||
generating the header fields SHOULD usually include a mailto based
|
||||
command, in addition to any other protocols used, in order to support
|
||||
users who do not have access to non-mail-based protocols.
|
||||
|
||||
Implementing these fields will be optional. Significant functionality
|
||||
and convenience can be gained by including them, however. Many list
|
||||
managers, especially as the proposal first gains acceptance, MAY
|
||||
choose to implement only one or two of the fields. The List-Help
|
||||
field is the most useful individual field since it provides an access
|
||||
point to detailed user support information, and accommodates almost
|
||||
all existing list managers command sets. The List-Subscribe and
|
||||
List-Unsubscribe fields are also very useful, but cannot describe
|
||||
some list manager syntaxes at this time (those which require variable
|
||||
substitution). See appendix A.5 for an explanation.
|
||||
|
||||
The description of command syntax provided by the fields can be used
|
||||
by mail client applications to provide simplified and consistent user
|
||||
access to email distribution list functions. This could take the form
|
||||
of menu items, push buttons, or other user interface elements. The
|
||||
intent is to simplify the user experience, providing a common
|
||||
interface to the often cryptic and varied mailing list manager
|
||||
commands.
|
||||
|
||||
Consideration has been given to avoiding the creation of too many
|
||||
fields, while at the same time avoiding the overloading of individual
|
||||
fields and keeping the syntax clear and simple.
|
||||
|
||||
The use of these fields does not remove the requirement to support
|
||||
the -Request command address for mailing lists [RFC2142].
|
||||
|
||||
2. The Command Syntax
|
||||
|
||||
The list header fields are subject to the encoding and character
|
||||
restrictions for mail headers as described in [RFC822]. Additionally,
|
||||
the URL content is further restricted to the set of URL safe
|
||||
characters [RFC1738].
|
||||
|
||||
The contents of the list header fields mostly consist of angle-
|
||||
bracket ('<', '>') enclosed URLs, with internal whitespace being
|
||||
ignored. MTAs MUST NOT insert whitespace within the brackets, but
|
||||
client applications should treat any whitespace, that might be
|
||||
inserted by poorly behaved MTAs, as characters to ignore.
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 2]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
A list of multiple, alternate, URLs MAY be specified by a comma-
|
||||
separated list of angle-bracket enclosed URLs. The URLs have order of
|
||||
preference from left to right. The client application should use the
|
||||
left most protocol that it supports, or knows how to access by a
|
||||
separate application. By this mechanism, protocols like http may be
|
||||
specified while still providing the basic mailto support for those
|
||||
clients who do not have access to non-mail protocols. The client
|
||||
should only use one of the available URLs for a command, using
|
||||
another only if the first one used failed.
|
||||
|
||||
The use of URLs allows for the use of the syntax with existing URL
|
||||
supporting applications. As the standard for URLs is extended, the
|
||||
list header fields will gain the benefit of those extensions.
|
||||
Additionally, the use of URLs provides access to multiple transport
|
||||
protocols (such as ftp and http) although it is expected that the
|
||||
"mailto" protocol [RFC2368] will be the focus of most use of the list
|
||||
header fields. Use of non-mailto protocols should be considered in
|
||||
light of those users who do not have access to the specified
|
||||
mechanism (those who only have email - with no web access).
|
||||
|
||||
Command syntaxes requiring variable fields to be set by the client
|
||||
(such as including the user's email address within a command) are not
|
||||
supported by this implementation. However, systems using such
|
||||
syntaxes SHOULD still take advantage of the List-Help field to
|
||||
provide the user with detailed instructions as needed or - perhaps
|
||||
more usefully - provide access to some form of structured command
|
||||
interface such as an HTML-based form.
|
||||
|
||||
The additional complications of supporting variable fields within the
|
||||
command syntax was determined to be too difficult to support by this
|
||||
protocol and would compromise the likelihood of implementation by
|
||||
software authors.
|
||||
|
||||
To allow for future extension, client applications MUST follow the
|
||||
following guidelines for handling the contents of the header fields
|
||||
described in this document:
|
||||
|
||||
1) Except where noted for specific fields, if the content of the
|
||||
field (following any leading whitespace, including comments)
|
||||
begins with any character other than the opening angle bracket
|
||||
'<', the field SHOULD be ignored.
|
||||
|
||||
2) Any characters following an angle bracket enclosed URL SHOULD be
|
||||
ignored, unless a comma is the first non-whitespace/comment
|
||||
character after the closing angle bracket.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 3]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
3) If a sub-item (comma-separated item) within the field is not an
|
||||
angle-bracket enclosed URL, the remainder of the field (the
|
||||
current, and all subsequent, sub-items) SHOULD be ignored.
|
||||
|
||||
3. The List Header Fields
|
||||
|
||||
This document presents header fields which will provide the
|
||||
command syntax description for the 'core' and key secondary
|
||||
functions of most email distribution lists. The fields implemented
|
||||
on a given list SHOULD be included on all messages distributed by
|
||||
the list (including command responses to individual users), and on
|
||||
other messages where the message clearly applies to one distinct
|
||||
list. There MUST be no more than one of each field present in any
|
||||
given message.
|
||||
|
||||
These fields MUST only be generated by mailing lists, not end
|
||||
users.
|
||||
|
||||
3.1. List-Help
|
||||
|
||||
The List-Help field is the most important of the header fields
|
||||
described in this document. It would be acceptable for a list
|
||||
manager to include only this field, since by definition it SHOULD
|
||||
direct the user to complete instructions for all other commands.
|
||||
Typically, the URL specified would request the help file, perhaps
|
||||
incorporating an HTML form for list commands, for the list, and
|
||||
alternatively provide access to an instructive website.
|
||||
|
||||
Examples:
|
||||
|
||||
List-Help: <mailto:list@host.com?subject=help> (List Instructions)
|
||||
List-Help: <mailto:list-manager@host.com?body=info>
|
||||
List-Help: <mailto:list-info@host.com> (Info about the list)
|
||||
List-Help: <http://www.host.com/list/>, <mailto:list-info@host.com>
|
||||
List-Help: <ftp://ftp.host.com/list.txt> (FTP),
|
||||
<mailto:list@host.com?subject=help>
|
||||
|
||||
3.2. List-Unsubscribe
|
||||
|
||||
The List-Unsubscribe field describes the command (preferably using
|
||||
mail) to directly unsubscribe the user (removing them from the list).
|
||||
|
||||
Examples:
|
||||
|
||||
List-Unsubscribe: <mailto:list@host.com?subject=unsubscribe>
|
||||
List-Unsubscribe: (Use this command to get off the list)
|
||||
<mailto:list-manager@host.com?body=unsubscribe%20list>
|
||||
List-Unsubscribe: <mailto:list-off@host.com>
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 4]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
List-Unsubscribe: <http://www.host.com/list.cgi?cmd=unsub&lst=list>,
|
||||
<mailto:list-request@host.com?subject=unsubscribe>
|
||||
|
||||
3.3. List-Subscribe
|
||||
|
||||
The List-Subscribe field describes the command (preferably using
|
||||
mail) to directly subscribe the user (request addition to the list).
|
||||
|
||||
Examples:
|
||||
|
||||
List-Subscribe: <mailto:list@host.com?subject=subscribe>
|
||||
List-Subscribe: <mailto:list-request@host.com?subject=subscribe>
|
||||
List-Subscribe: (Use this command to join the list)
|
||||
<mailto:list-manager@host.com?body=subscribe%20list>
|
||||
List-Subscribe: <mailto:list-on@host.com>
|
||||
List-Subscribe: <http://www.host.com/list.cgi?cmd=sub&lst=list>,
|
||||
<mailto:list-manager@host.com?body=subscribe%20list>
|
||||
|
||||
3.4. List-Post
|
||||
|
||||
The List-Post field describes the method for posting to the list.
|
||||
This is typically the address of the list, but MAY be a moderator, or
|
||||
potentially some other form of submission. For the special case of a
|
||||
list that does not allow posting (e.g., an announcements list), the
|
||||
List-Post field may contain the special value "NO".
|
||||
|
||||
Examples:
|
||||
|
||||
List-Post: <mailto:list@host.com>
|
||||
List-Post: <mailto:moderator@host.com> (Postings are Moderated)
|
||||
List-Post: <mailto:moderator@host.com?subject=list%20posting>
|
||||
List-Post: NO (posting not allowed on this list)
|
||||
|
||||
3.5. List-Owner
|
||||
|
||||
The List-Owner field identifies the path to contact a human
|
||||
administrator for the list. The URL MAY contain the address of a
|
||||
administrator for the list, the mail system administrator, or any
|
||||
other person who can handle user contact for the list. There is no
|
||||
need to specify List-Owner if it is the same person as the mail
|
||||
system administrator (postmaster).
|
||||
|
||||
Examples:
|
||||
|
||||
List-Owner: <mailto:listmom@host.com> (Contact Person for Help)
|
||||
List-Owner: <mailto:grant@foo.bar> (Grant Neufeld)
|
||||
List-Owner: <mailto:josh@foo.bar?Subject=list>
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 5]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
3.6. List-Archive
|
||||
|
||||
The List-Archive field describes how to access archives for the list.
|
||||
|
||||
Examples:
|
||||
|
||||
List-Archive: <mailto:archive@host.com?subject=index%20list>
|
||||
List-Archive: <ftp://ftp.host.com/pub/list/archive/>
|
||||
List-Archive: <http://www.host.com/list/archive/> (Web Archive)
|
||||
|
||||
4. Supporting Nested Lists
|
||||
|
||||
A list that is a sublist for another list in a nested mailing list
|
||||
hierarchy will need to modify some of the List- header fields, while
|
||||
leaving others as the parent list set them.
|
||||
|
||||
Sublists SHOULD remove the parent list's List-Help, List-Subscribe,
|
||||
List-Unsubscribe and List-Owner fields, and SHOULD insert their own
|
||||
versions of those fields.
|
||||
|
||||
If the sublist provides its own archive, it SHOULD replace the List-
|
||||
Archive with its own. Otherwise, it MUST leave the List-Archive field
|
||||
untouched.
|
||||
|
||||
Dependant on how postings to the list are handled, the sublist MAY
|
||||
replace the List-Post field. The appropriateness of whether to
|
||||
replace List-Post is left to the determination of the individual list
|
||||
managers. If the intention is that postings should be distributed to
|
||||
all members of the primary list, List-Post should not be changed by a
|
||||
sublist in such a way that postings will be distributed only to
|
||||
members of the sublist.
|
||||
|
||||
5. Security Considerations
|
||||
|
||||
There are very few new security concerns generated with this
|
||||
proposal. Message headers are an existing standard, designed to
|
||||
easily accommodate new types. There may be concern with multiple
|
||||
fields being inserted or headers being forged, but these are problems
|
||||
inherent in Internet email, not specific to the protocol described in
|
||||
this document. Further, the implications are relatively harmless.
|
||||
|
||||
Mail list processors should not allow any user-originated list header
|
||||
fields to pass through to their lists, lest they confuse the user and
|
||||
have the potential to create security problems.
|
||||
|
||||
On the client side, there may be some concern with posts or commands
|
||||
being sent in error. It is required that the user have a chance to
|
||||
confirm any action before it is executed. In the case of mailto, it
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 6]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
may be appropriate to create the correctly formatted message without
|
||||
sending it, allowing the user to see exactly what is happening and
|
||||
giving the user the opportunity to approve or discard the message
|
||||
before it is sent.
|
||||
|
||||
All security considerations for the use of URLs [RFC1738] apply
|
||||
equally to this protocol. Mail client applications should not support
|
||||
list header field URLs which could compromise the security of the
|
||||
user's system. This includes the "file://" URL type which could
|
||||
potentially be used to trigger the execution of a local application
|
||||
on some user systems.
|
||||
|
||||
6. Acknowledgements
|
||||
|
||||
The numerous participants of the List-Header [5], ListMom-Talk [6],
|
||||
List-Managers and MIDA-Mail mailing lists contributed much to the
|
||||
formation and structure of this document.
|
||||
|
||||
Keith Moore <moore@cs.utk.edu> and Christopher Allen
|
||||
<ChristopherA@consensus.com> provided guidance on the standards
|
||||
process.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 7]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
A. Background Discussion
|
||||
|
||||
This proposal arose from discussions started on the ListMom-Talk
|
||||
Discussion List [6]. When the discussion reached a sufficient level,
|
||||
a separate list was formed for discussing this proposal, the List
|
||||
Headers Mail List [5] for deeper discussion. We have included
|
||||
summaries of key issues raised, in order to show some of the
|
||||
alternatives examined and reasons for our decisions.
|
||||
|
||||
A.1. Multiple header fields vs. a single header field
|
||||
|
||||
Use of a single header field for transporting command meta-syntax was
|
||||
rejected for a number of reasons.
|
||||
|
||||
Such a field would require the creation of a new meta-syntax in order
|
||||
to describe the list commands (as opposed to the use of the widely
|
||||
deployed URL syntax which was chosen for this implementation). Every
|
||||
additional layer of complexity and newness reduces the likelihood of
|
||||
actual implementation because it will require additional work to
|
||||
support. Also, by using the existing URL syntax, we can profit from
|
||||
the end users' knowledge of that syntax and ability to use it even if
|
||||
their client applications do not support the list header fields.
|
||||
|
||||
Restricting the transport of meta-syntax to the use of a single
|
||||
header field also introduces complications with header field size
|
||||
limitations. Most individual commands can easily be described in a
|
||||
single line, but describing a multitude of commands can take up many
|
||||
lines in the field and runs a greater risk of being modified by an
|
||||
existing server on route.
|
||||
|
||||
The client implementation is also easier with multiple fields, since
|
||||
each command can be supported and implemented individually,
|
||||
completely independent of the others. Thus, some list managers or
|
||||
mail clients can choose to implement a subset of the fields based on
|
||||
the specific needs of their individual lists.
|
||||
|
||||
Finally, the format described in this document is simple and well
|
||||
recognized, which reduces the chances of errors in implementation and
|
||||
parsing.
|
||||
|
||||
A.2. URLs vs. parameter lists
|
||||
|
||||
URLs are already an established syntax which is flexible, well-
|
||||
defined, and in wide spread use. As its definition matures and
|
||||
expands, the abilities of the list fields will grow as well, without
|
||||
requiring modification of this proposal. URLs are well prepared to
|
||||
handle future protocols and developments, and can easily describe the
|
||||
different existing access protocols such as mailto, http and ftp.
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 8]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
Many clients already have functionality for recognizing, parsing, and
|
||||
evaluating URLs, either internally or by passing the request to a
|
||||
helper application. This makes implementation easier and more
|
||||
realistic. As an example, this existing support for URL parsing
|
||||
allowed us to add prototype list header functionality to existing
|
||||
mail clients (Eudora and Emailer for the Macintosh) without modifying
|
||||
their source code.
|
||||
|
||||
A.3. Why not just create a standard command language?
|
||||
|
||||
A standard command language, supported by all email list services,
|
||||
would go a long way to reducing the problems of list access that
|
||||
currently plague existing services. It would reduce the amount of
|
||||
learning required by end users and allow for a number of common
|
||||
support tools to be developed.
|
||||
|
||||
However, such standardization does pose problems in the areas of
|
||||
multi-lingual support and the custom needs of individual mailing
|
||||
lists. The development of such a standard is also expected to be met
|
||||
with a slow adoption rate by software developers and list service
|
||||
providers.
|
||||
|
||||
These points do not preclude the development of such a standard (in
|
||||
fact, it would suggest that we should start sooner rather than
|
||||
later), but we do need a solution that can be widely supported by the
|
||||
current list services.
|
||||
|
||||
We can support most existing list manager command syntaxes without a
|
||||
standard command language. By using URLs, we allow alternate access
|
||||
methods a standard command language probably wouldn't enable, such as
|
||||
web based control.
|
||||
|
||||
Finally, client support for a standard command language is not at all
|
||||
clear or necessarily simple to implement. The variety and large
|
||||
number of commands existing today would require complicated user
|
||||
interfaces which could be confusing and difficult to implement. By
|
||||
restricting this proposal to the core functions, the client
|
||||
|
||||
implementation is much simpler, which significantly increases the
|
||||
likelihood of implementation (as evidenced by the support already
|
||||
announced by a number of client and server application authors).
|
||||
|
||||
A.4. Internationalization
|
||||
|
||||
Multilingual support is up to the URL standard. If URLs support it,
|
||||
then the List- header fields support it. This is another advantage of
|
||||
using URLs as the building blocks for the list header fields.
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 9]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
A.5. Variable Substitution
|
||||
|
||||
Variables would allow the List- header fields to accommodate nearly
|
||||
every existing list manager. However, it would immeasurably increase
|
||||
the complexity of the entire proposal, and possibly involve
|
||||
redefining the URL standard, or force us to use something more
|
||||
complicated (and hence more difficult to implement) than URLs to
|
||||
describe the command syntax.
|
||||
|
||||
Parameters would either have to be mandatory (i.e. the user agent
|
||||
doesn't submit the message if it doesn't know what text to
|
||||
substitute) or you need a way to say "if you know this parameter, add
|
||||
its text here; otherwise, do this" where "this" is either: (a)
|
||||
substitute a constant string, or (b) fail.
|
||||
|
||||
The reason you would want a facility like this is because some list
|
||||
server applications insist on having certain parameters like users'
|
||||
names, which the user agent might or might not know. e.g. listserv
|
||||
insists on having a first name and a last name if you supply either
|
||||
one.
|
||||
|
||||
Which could lead to something like the UNIX shell syntax, where
|
||||
${foo-bar} means substitute the value of parameter "foo" if "foo" is
|
||||
defined, else substitute the string "bar". Perhaps $foo would mean
|
||||
"substitute the value of parameter foo if it is defined, else
|
||||
substitute the empty string"
|
||||
|
||||
This all seems far too complicated for the gains involved, especially
|
||||
since the use of variables can often be avoided.
|
||||
|
||||
The use of variables in the command syntaxes of list services appears
|
||||
to be lessening and does not, in any case, apply to all commands.
|
||||
While the unsubscribe and subscribe command header fields may not be
|
||||
usable by those systems which require the use of variables, the help
|
||||
field will still provide end users with a consistent point of access
|
||||
through which they can get support for their use of the list.
|
||||
|
||||
A.6. Why not use a specialized MIME part instead of header fields?
|
||||
|
||||
MIME parts were considered, but because most mail clients currently
|
||||
either don't support MIME or are not equipped to handle such
|
||||
specialized parts - such an implementation would result in problems
|
||||
for end users. It is also not as easy for many list servers to
|
||||
implement MIME as it is to implement new header fields.
|
||||
|
||||
However, we are looking at the design of a MIME part to more fully
|
||||
describe list command syntax, as well as trying to find ways to get
|
||||
it supported by the applicable software.
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 10]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
A.7. Why include a Subscribe command?
|
||||
|
||||
Subscribe and Unsubscribe are the key commands needed by almost every
|
||||
list. Other commands, such as digest mode, are not as widely
|
||||
supported.
|
||||
|
||||
Additionally, users who have unsubscribed (before going on vacation,
|
||||
or for whatever other reason) may want to resubscribe to a list. Or,
|
||||
a message may be forwarded/bounced from a subscriber to a non-
|
||||
subscriber. Or, the user may change addresses and want to subscribe
|
||||
from their new address. Having the List-Subscribe field available
|
||||
could certainly help in all these cases.
|
||||
|
||||
A.8. The Dangers of Header Bloat
|
||||
|
||||
At what point are there just too many header fields? It really
|
||||
varies on a list by list basis. On some lists, the majority of users
|
||||
will never be aware of a field unless the client software provides
|
||||
some alternative user interface to it (akin to the Reply-To field).
|
||||
On others, the users will often see the header fields of messages and
|
||||
would be able to recognize the function of the URLs contained within.
|
||||
|
||||
The flexibility afforded by the protocol described in this document
|
||||
(in that the header fields may be individually implemented as deemed
|
||||
appropriate) provides list administrators with sufficient 'room to
|
||||
maneuver' to meet their individual needs.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 11]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
B. Client Implementation
|
||||
|
||||
B.1. Guidelines
|
||||
|
||||
For 'mailto' URL based commands, mail client applications may choose
|
||||
to provide specialized feedback (such as presenting a dialog or
|
||||
alert), instead of the actual command email message, asking for
|
||||
command confirmation from the user. The feedback should identify the
|
||||
message destination and command within a more descriptive
|
||||
explanation. For example:
|
||||
|
||||
"Do you want to send the unsubscription command 'unsubscribe
|
||||
somelist' to 'somelist-request@some.host.com'? Sending the command
|
||||
will result in your removal from the associated list."
|
||||
|
||||
If the user has multiple email addresses supported by the mail
|
||||
client, the client application should prompt the user for which
|
||||
address to use when subscribing or performing some other action where
|
||||
the address to use cannot be specifically determined. When
|
||||
unsubscribing or such, the address that is subscribed should be used,
|
||||
unless that is not known by the application and cannot be determined
|
||||
from the message headers.
|
||||
|
||||
B.2. Implementation Options
|
||||
|
||||
The following implementation possibilities are suggested here to give
|
||||
some idea as to why these new header fields will be useful, and how
|
||||
they could be supported.
|
||||
|
||||
In most cases, it may be helpful to disable the interface for the
|
||||
commands when not applicable to the currently selected message.
|
||||
|
||||
B.2.1. Key combinations and command lines
|
||||
|
||||
On text based systems which utilize command lines or key
|
||||
combinations, each field could be implemented as a separate command.
|
||||
Thus one combination would subscribe the user, another would
|
||||
unsubscribe, a third request help, etc. The commands would only be
|
||||
available on messages containing the list header fields.
|
||||
|
||||
B.2.2. Menu items
|
||||
|
||||
On graphical systems which have menus, these commands could take the
|
||||
form of a menu or sub-menu of items. For example, a "Lists" menu
|
||||
might appear when viewing messages containing the header fields, with
|
||||
items named "Subscribe", "Unsubscribe", "Get Help", "Post Message to
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 12]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
List", "Contact List Owner" and "Access List Archive". This menu
|
||||
could be disabled when not applicable to the current message or
|
||||
disappear entirely.
|
||||
|
||||
B.2.3. Push Buttons and Pallettes
|
||||
|
||||
On graphical window systems, buttons could be placed in the window of
|
||||
the message, a toolbar, or in a floating pallette of their own. Each
|
||||
button could correspond to a command, with names "Subscribe",
|
||||
"Unsubscribe", "Get Help", "Post to List", "List Owner" and
|
||||
"Archive". These buttons or pallettes could be disabled when not
|
||||
applicable to the current message or disappear entirely.
|
||||
|
||||
B.2.4 Feedback to the User
|
||||
|
||||
If using a dialog interface (or other feedback element) the client
|
||||
application MUST include an option for the user to review (and
|
||||
possibly modify) the message before it is sent. The application may
|
||||
also find it useful to provide a link to more detailed context-
|
||||
sensitive assistance about mail list access in general.
|
||||
|
||||
References
|
||||
|
||||
[RFC822] Crocker, D., "Standard for the Format of ARPA
|
||||
Internet Text Messages", STD 11, RFC 822, August 1982.
|
||||
|
||||
[RFC1738] Berners-Lee, T., Masinter, L., and M. McCahill,
|
||||
"Uniform Resource Locators (URL)" RFC 1738, December 1994.
|
||||
|
||||
[RFC2142] Crocker, D., "Mailbox Names for Common Services, Roles and
|
||||
Functions", RFC 2142, May 1997.
|
||||
|
||||
[RFC2368] Hoffman, P., Masinter, L., and J. Zawinski, "The mailto URL
|
||||
scheme", RFC 2368, July 1998.
|
||||
|
||||
[5] "List-Header" Mail list. list-header@list.nisto.com
|
||||
<URL:http://www.nisto.com/listspec/mail/>
|
||||
<URL:http://www.nisto.com/listspec/>
|
||||
|
||||
[6] "ListMom-Talk" Mail list. listmom-talk@skyweyr.com
|
||||
<URL:http://cgi.skyweyr.com/ListMom.Home>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 13]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
Editors' Addresses
|
||||
|
||||
Joshua D. Baer
|
||||
Box 273
|
||||
4902 Forbes Avenue
|
||||
Pittsburgh, PA 15213-3799
|
||||
USA
|
||||
|
||||
EMail: josh@skyweyr.com
|
||||
|
||||
|
||||
Grant Neufeld
|
||||
Calgary, Alberta
|
||||
Canada
|
||||
|
||||
EMail: grant@acm.org
|
||||
Web: http://www.nisto.com/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 14]
|
||||
|
||||
RFC 2369 URLs as Meta-Syntax July 1998
|
||||
|
||||
|
||||
Full Copyright Statement
|
||||
|
||||
Copyright (C) The Internet Society (1998). All Rights Reserved.
|
||||
|
||||
This document and translations of it may be copied and furnished to
|
||||
others, and derivative works that comment on or otherwise explain it
|
||||
or assist in its implementation may be prepared, copied, published
|
||||
and distributed, in whole or in part, without restriction of any
|
||||
kind, provided that the above copyright notice and this paragraph are
|
||||
included on all such copies and derivative works. However, this
|
||||
document itself may not be modified in any way, such as by removing
|
||||
the copyright notice or references to the Internet Society or other
|
||||
Internet organizations, except as needed for the purpose of
|
||||
developing Internet standards in which case the procedures for
|
||||
copyrights defined in the Internet Standards process must be
|
||||
followed, or as required to translate it into languages other than
|
||||
English.
|
||||
|
||||
The limited permissions granted above are perpetual and will not be
|
||||
revoked by the Internet Society or its successors or assigns.
|
||||
|
||||
This document and the information contained herein is provided on an
|
||||
"AS IS" basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING
|
||||
TASK FORCE DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
|
||||
BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION
|
||||
HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Neufeld & Baer Standards Track [Page 15]
|
||||
|
||||