Compare commits

..
24 Commits
Author SHA1 Message Date
Linus Rath ec5593f567 Merge branch 'dev' into main 2026-04-23 00:10:22 +02:00
Linus Rath c30c38a7af chore: update version to 1.5.0 2026-04-23 00:07:34 +02:00
Linus Rath 7494fc1776 chore: update version to 1.5.0 2026-04-22 23:49:44 +02:00
Linus Rath 16fe92d2e9 chore: update version to 1.5.0 2026-04-22 23:31:32 +02:00
Linus Rath 3544e6a9e7 chore: bump dependencies to latest compatible versions 2026-04-22 23:03:05 +02:00
Linus Rath dd1f3e11e6 fix: retry mailbox fetch on first login to handle lazy provisioning #217 2026-04-22 22:45:49 +02:00
Linus Rath 6503482b55 feat: support resizing events from the top edge 2026-04-22 21:14:28 +02:00
Linus Rath cab57f6cd7 feat: add timezone-aware formatting for event start times and update utcEnd on duration change 2026-04-22 21:05:41 +02:00
Linus Rath 27f4fbdce4 feat: optimize layout of overlapping events in calendar 2026-04-22 20:53:04 +02:00
Linus Rath 39a228b20e feat: streamline mobile email viewer header layout 2026-04-22 20:50:12 +02:00
Linus Rath 4b069808d6 feat: add isMobile prop to calendar views and time grid interactions 2026-04-22 20:37:00 +02:00
Linus Rath 468851ff25 feat: show full folder path in move/drop toast 2026-04-22 00:14:49 +02:00
Linus Rath b7374570c8 feat: implement bulk email moving functionality in email store 2026-04-22 00:10:40 +02:00
Linus Rath 3c9fa5dc25 feat: implement batch archiving of emails 2026-04-22 00:05:38 +02:00
Linus Rath 3ade1c6473 fix: update mailbox retrieval logic in archive handling to use fresh state 2026-04-22 00:00:43 +02:00
Linus Rath 1810a474a2 fix: enhance error message for mailbox creation failure 2026-04-22 00:00:01 +02:00
Linus Rath 40982bc37b fix: align hover styles and selection-toggle target with focused item 2026-04-21 23:50:55 +02:00
Linus Rath 76f6149841 feat: add setting to toggle alphabetical grouping in contacts list 2026-04-21 23:22:18 +02:00
Linus Rath 7d6a3c8c76 feat: group contacts by first letter with sticky section headers 2026-04-21 23:15:13 +02:00
Linus Rath 361ad49f5f fix: read matchMedia synchronously on client to prevent layout flicker 2026-04-21 23:02:21 +02:00
Linus Rath f9aa5cbaee fix: skip checkAuth on route change when already authenticated 2026-04-21 22:54:02 +02:00
Linus Rath 3f36045990 feat: reorganize settings into 6 groups with clearer tabs 2026-04-21 22:22:02 +02:00
Linus Rath 9a44babcf1 feat: add collapsible details to calendar invitation banner and update sanitization config for iframe rendering 2026-04-21 21:21:42 +02:00
Linus Rath f032758303 feat: revamp contact detail, filters, and add photo/print/duplicate 2026-04-21 21:16:54 +02:00
114 changed files with 4966 additions and 93999 deletions
+44
View File
@@ -1,5 +1,49 @@
# Changelog # 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) ## 1.4.14 (2026-04-16)
Thank you for your donations: Thank you for your donations:
+124
View File
@@ -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
+69 -248
View File
@@ -8,12 +8,11 @@
# Bulwark Webmail # Bulwark Webmail
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/> A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
Built with Next.js and the JMAP protocol.
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.4.14-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.5.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
## Screenshots ## 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> <table>
<tr> <tr>
<td width="50%"> <td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
<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>
</tr> </tr>
<tr> <tr>
<td width="50%"> <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>
<img src="screenshots/contacts.png" width="100%" alt="Contacts"> </tr>
<tr>
**Contacts** - Contact management with groups and vCard support <td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</td> </tr>
<td width="50%"> <tr>
<td><sub><b>Themes</b> bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
<img src="screenshots/files.png" width="100%" alt="File browser"> <td><sub><b>Plugins</b> extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
</tr>
**Files** - Cloud file browser with upload, preview, and folder navigation <tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
</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> </tr>
</table> </table>
<details> ## Overview
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
<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> 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.
<td width="50%">
<img src="screenshots/settings.png" width="100%" alt="Settings"> Full feature list: **[FEATURES.md](FEATURES.md)**.
**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
--- ---
## Quick Start ## Quick Start
### Docker (recommended) ### Docker
```bash ```bash
docker run -d -p 3000:3000 \ docker run -d -p 3000:3000 \
@@ -255,7 +82,7 @@ Or with Docker Compose:
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL # Edit .env.local set JMAP_SERVER_URL
docker compose up -d docker compose up -d
``` ```
@@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
cd webmail cd webmail
npm install npm install
cp .env.example .env.local cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL # Edit .env.local set JMAP_SERVER_URL
npm run build && npm start npm run build && npm start
``` ```
### Development ### Development
```bash ```bash
npm run dev # Start dev server (mock JMAP server included) npm run dev # Dev server with a mock JMAP server
npm run typecheck # Type checking npm run typecheck
npm run lint # Linting npm run lint
``` ```
## Configuration ## Configuration
Edit `.env.local`: All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env ```env
# Required # Required
@@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail APP_NAME=My Webmail
``` ```
All variables are **runtime** - Docker deployments can be configured without rebuilding.
<details> <details>
<summary>Server Listen Address</summary> <summary>Server listen address</summary>
```env ```env
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6 HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000 # Default listen port PORT=3000
``` ```
</details> </details>
<details> <details>
<summary>OAuth2/OIDC (SSO)</summary> <summary>OAuth2 / OIDC</summary>
```env ```env
OAUTH_ENABLED=true OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) OAUTH_ISSUER_URL= # optional, for external IdPs
``` ```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. 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>
<details> <details>
<summary>Remember Me & Settings Sync</summary> <summary>Session & settings sync</summary>
```env ```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32 SESSION_SECRET= # openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker) 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). 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`.
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details> </details>
<details> <details>
<summary>Custom JMAP Endpoint</summary> <summary>Custom JMAP endpoint</summary>
```env ```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> </details>
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
```env ```env
APP_NAME=My Webmail APP_NAME=My Webmail
APP_SHORT_NAME=Webmail # Home-screen label on mobile APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail # Shown during PWA install APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6 # Browser chrome color PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg APP_LOGO_DARK_URL=/branding/logo-dark.svg
@@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
</details> </details>
<details> <details>
<summary>Extension Directory</summary> <summary>Extension directory</summary>
```env ```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
@@ -382,13 +206,13 @@ Enables the admin marketplace for browsing and installing plugins and themes.
</details> </details>
<details> <details>
<summary>Stalwart Integration & Logging</summary> <summary>Stalwart integration & logging</summary>
```env ```env
STALWART_FEATURES=true # Password change, sieve filters, etc. STALWART_FEATURES=true # password change, Sieve filters, etc.
LOG_FORMAT=text # "text" or "json" LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug" LOG_LEVEL=info # error | warn | info | debug
``` ```
</details> </details>
@@ -423,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
## Why Stalwart? ## 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 ## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. See [CONTRIBUTING.md](CONTRIBUTING.md).
## License ## License
[GNU AGPL v3](LICENSE) [GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
This repository also preserves the original MIT attribution notice for the
fork lineage in [NOTICE](NOTICE).
## Acknowledgments ## 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.
+1 -1
View File
@@ -1 +1 @@
1.4.14 1.5.0
+8 -1
View File
@@ -126,8 +126,15 @@ export default function CalendarPage() {
} }
}, [events, detailEvent]); }, [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(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
+39 -20
View File
@@ -98,13 +98,20 @@ export default function ContactsPage() {
// Panel resize state - contact list // Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => { 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 [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(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
@@ -172,21 +179,6 @@ export default function ContactsPage() {
return getGroupMembers(activeCategory.groupId); return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]); }, [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) => { const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category); setActiveCategory(category);
clearSelection(); clearSelection();
@@ -306,6 +298,24 @@ export default function ContactsPage() {
setView("bulk-add-to-group"); setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]); }, [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>) => { const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) { if (supportsSync && client) {
await createContact(client, data); await createContact(client, data);
@@ -607,6 +617,16 @@ export default function ContactsPage() {
contact={selectedContact} contact={selectedContact}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
: undefined
}
onDuplicate={
selectedContact
? () => void handleDuplicateContact(selectedContact)
: undefined
}
isMobile={isMobile} isMobile={isMobile}
/> />
); );
@@ -702,7 +722,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact} onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew} onCreateNew={handleCreateNew}
categoryLabel={categoryLabel}
className="flex-1" className="flex-1"
selectedContactIds={selectedContactIds} selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection} onToggleSelection={toggleContactSelection}
@@ -726,7 +745,7 @@ export default function ContactsPage() {
setIsListResizing(false); setIsListResizing(false);
localStorage.setItem("contacts-list-width", String(listWidth)); 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"); }}
/> />
)} )}
</> </>
+8 -1
View File
@@ -105,8 +105,15 @@ export default function FilesPage() {
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null; 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(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
+42 -21
View File
@@ -8,7 +8,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer"; import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; 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 { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
@@ -455,8 +455,15 @@ export default function Home() {
document.title = title; document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); }, [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(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
@@ -493,7 +500,10 @@ export default function Home() {
// Load mailboxes and emails when authenticated (only if not already loaded) // Load mailboxes and emails when authenticated (only if not already loaded)
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) { 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 { try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes) // First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([ await Promise.all([
@@ -505,6 +515,15 @@ export default function Home() {
const state = useEmailStore.getState(); const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox; 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 // Fetch emails for the selected mailbox
if (selectedMailboxId) { if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId); await fetchEmails(client, selectedMailboxId);
@@ -538,6 +557,12 @@ export default function Home() {
} }
}; };
loadData(); loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
};
} }
// Cleanup push notifications on unmount // Cleanup push notifications on unmount
@@ -813,8 +838,11 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => { const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return; if (!client || !emailToArchive) return;
// Find archive mailbox // Read fresh mailboxes from the store batch archive calls this in a loop,
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive"); // 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; if (!archiveMailbox) return;
const { archiveMode } = useSettingsStore.getState(); const { archiveMode } = useSettingsStore.getState();
@@ -823,14 +851,12 @@ export default function Home() {
if (archiveMode === 'single') { if (archiveMode === 'single') {
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
} else { } else {
// Determine year/month from the email's received date
const emailDate = new Date(emailToArchive.receivedAt); const emailDate = new Date(emailToArchive.receivedAt);
const year = emailDate.getFullYear().toString(); const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0'); const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
const archiveId = archiveMailbox.originalId || archiveMailbox.id; const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Find or create year subfolder under archive let yearMailbox = currentMailboxes.find(
let yearMailbox = mailboxes.find(
m => m.name === year && m.parentId === archiveId m => m.name === year && m.parentId === archiveId
); );
if (!yearMailbox) { if (!yearMailbox) {
@@ -841,9 +867,9 @@ export default function Home() {
if (archiveMode === 'year') { if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else { } else {
// archiveMode === 'month' - find or create month subfolder under year
const yearId = yearMailbox.originalId || yearMailbox.id; 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 m => m.name === month && m.parentId === yearId
); );
if (!monthMailbox) { if (!monthMailbox) {
@@ -1492,9 +1518,12 @@ export default function Home() {
} else { } else {
selectAllEmails(); selectAllEmails();
} }
} else { } else if (emails.length > 0) {
// Enter selection mode by selecting the first email const currentId = selectedEmail?.id;
if (emails.length > 0) toggleEmailSelection(emails[0].id); const target = currentId && emails.some((e) => e.id === currentId)
? currentId
: emails[0].id;
toggleEmailSelection(target);
} }
}} }}
className={cn( 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}> <ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer <EmailViewer
email={selectedEmail} email={selectedEmail}
+152 -82
View File
@@ -9,7 +9,6 @@ import {
LogOut, LogOut,
Settings as SettingsIcon, Settings as SettingsIcon,
Palette, Palette,
Mail,
User, User,
Shield, Shield,
UserPen, UserPen,
@@ -20,17 +19,27 @@ import {
FolderOpen, FolderOpen,
Tags, Tags,
HardDrive, HardDrive,
Wrench,
BookUser, BookUser,
KeyRound, KeyRound,
PanelLeftClose, PanelLeftClose,
Bell, Bell,
Puzzle, Puzzle,
LayoutGrid,
BookOpen,
PenLine,
EyeOff,
Languages,
Info,
Bug,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings'; 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 { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings'; import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings'; import { 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 { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings'; import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-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 { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-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 { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils'; 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 Tab =
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; | '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 { interface TabDef {
id: Tab; id: Tab;
@@ -74,28 +109,54 @@ interface TabDef {
} }
const tabIcons: Record<Tab, LucideIcon> = { const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
notifications: Bell,
account: User, account: User,
security: Shield, language: Languages,
notifications: Bell,
appearance: Palette,
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
identities: UserPen, identities: UserPen,
encryption: KeyRound,
vacation: PalmtreeIcon, vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter, filters: Filter,
templates: FileText, templates: FileText,
folders: FolderOpen, folders: FolderOpen,
keywords: Tags, keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
files: HardDrive, files: HardDrive,
sidebar_apps: PanelLeftClose, sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette, themes: Palette,
plugins: Puzzle, 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() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
@@ -107,13 +168,7 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig(); const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(() => { const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [mobileShowContent, setMobileShowContent] = useState(false); const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
@@ -124,21 +179,28 @@ export default function SettingsPage() {
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256); 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(() => { useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => { checkAuth().finally(() => {
setInitialCheckDone(true); setInitialCheckDone(true);
}); });
}, [checkAuth]); }, [checkAuth]);
// Listen for tab change events from child components // Listen for tab change events from child components (with legacy migration)
useEffect(() => { useEffect(() => {
const handler = (e: Event) => { const handler = (e: Event) => {
const tab = (e as CustomEvent).detail as Tab; const raw = (e as CustomEvent).detail as string;
if (tab) { if (!raw) return;
setActiveTab(tab); const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ } setActiveTab(tab);
} try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
}; };
window.addEventListener('settings-tab-change', handler); window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('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 supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [ const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' }, // General
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: '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: '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 }] : []), // Appearance
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []), { id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
...(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 }] : []), // Mail
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []), { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' }, { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []), { 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 }] : []), ...(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' }, { 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 }] : []), ...(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('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 }] : []), // Advanced
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' }, { 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 // Group tabs by category
@@ -191,6 +269,10 @@ export default function SettingsPage() {
})) }))
.filter((g) => g.items.length > 0); .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) => { const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId); setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } 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 = () => ( const renderTabContent = () => (
<> <>
{activeTab === 'appearance' && <AppearanceSettings />} {effectiveActiveTab === 'account' && <AccountSettings />}
{activeTab === 'email' && <EmailSettings />} {effectiveActiveTab === 'language' && <LanguageSettings />}
{activeTab === 'notifications' && <NotificationSettings />} {effectiveActiveTab === 'notifications' && <NotificationSettings />}
{activeTab === 'account' && <AccountSettings />} {effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'security' && <AccountSecuritySettings />} {effectiveActiveTab === 'layout' && <LayoutSettings />}
{activeTab === 'identities' && <IdentitySettings />} {effectiveActiveTab === 'reading' && <ReadingSettings />}
{activeTab === 'encryption' && <SmimeSettings />} {effectiveActiveTab === 'composing' && <ComposingSettings />}
{activeTab === 'vacation' && <VacationSettings />} {effectiveActiveTab === 'identities' && <IdentitySettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {effectiveActiveTab === 'vacation' && <VacationSettings />}
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>} {effectiveActiveTab === 'filters' && <FilterSettings />}
{activeTab === 'filters' && <FilterSettings />} {effectiveActiveTab === 'templates' && <TemplateSettings />}
{activeTab === 'templates' && <TemplateSettings />} {effectiveActiveTab === 'folders' && <FolderSettings />}
{activeTab === 'folders' && <FolderSettings />} {effectiveActiveTab === 'keywords' && <KeywordSettings />}
{activeTab === 'keywords' && <KeywordSettings />} {effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'files' && <FilesSettingsComponent />} {effectiveActiveTab === 'encryption' && <SmimeSettings />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />} {effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{activeTab === 'themes' && <ThemesSettings />} {effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'plugins' && <PluginsSettings />} {effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'advanced' && <AdvancedSettings />} {effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
</> </>
); );
// Mobile layout // Mobile layout
if (!isDesktop) { if (!isDesktop) {
// Mobile: show content view
if (mobileShowContent) { if (mobileShowContent) {
return ( return (
<div className="flex flex-col h-dvh bg-background"> <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"> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -244,14 +329,12 @@ export default function SettingsPage() {
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1> <h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div> </div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4"> <div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()} {renderTabContent()}
</div> </div>
</div> </div>
{/* Bottom Navigation */}
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -264,10 +347,8 @@ export default function SettingsPage() {
); );
} }
// Mobile: show tab list
return ( return (
<div className="flex flex-col h-dvh bg-background"> <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"> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -283,7 +364,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="py-2"> <div className="py-2">
{groupedTabs.map((group, groupIndex) => ( {groupedTabs.map((group, groupIndex) => (
@@ -319,7 +399,6 @@ export default function SettingsPage() {
))} ))}
</div> </div>
{/* Logout */}
<div className="border-t border-border px-5 py-3"> <div className="border-t border-border px-5 py-3">
<button <button
onClick={logout} onClick={logout}
@@ -331,7 +410,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Bottom Navigation */}
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -347,7 +425,6 @@ export default function SettingsPage() {
// Desktop layout // Desktop layout
return ( return (
<div className="flex h-dvh bg-background"> <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)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -366,7 +443,6 @@ export default function SettingsPage() {
)} )}
{!inlineApp && ( {!inlineApp && (
<> <>
{/* Settings Sidebar */}
<div <div
className={cn( className={cn(
"border-r border-border bg-secondary flex flex-col", "border-r border-border bg-secondary flex flex-col",
@@ -374,7 +450,6 @@ export default function SettingsPage() {
)} )}
style={{ width: `${settingsSidebarWidth}px` }} style={{ width: `${settingsSidebarWidth}px` }}
> >
{/* Header */}
<div className="p-4 border-b border-border"> <div className="p-4 border-b border-border">
<Button <Button
variant="ghost" variant="ghost"
@@ -387,7 +462,6 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs"> <div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-2 space-y-0.5"> <div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => ( {groupedTabs.map((group, groupIndex) => (
@@ -406,14 +480,14 @@ export default function SettingsPage() {
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={cn( className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5', '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' ? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground' : 'hover:bg-muted text-foreground'
)} )}
> >
<Icon className={cn( <Icon className={cn(
'w-4 h-4 shrink-0', '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.label}
{tab.experimental && ( {tab.experimental && (
@@ -430,7 +504,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Sidebar resize handle */}
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} 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'); }} onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/> />
{/* Settings Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8"> <div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-2.5 mb-2"> <div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" /> <SettingsIcon className="w-6 h-6 text-muted-foreground" />
@@ -452,7 +523,6 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6"> <div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()} {renderTabContent()}
</div> </div>
+1 -2
View File
@@ -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.', preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }], textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }], htmlBody: [],
bodyValues: { 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' }, 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>' },
}, },
}, },
{ {
+16 -3
View File
@@ -119,6 +119,7 @@ export function CalendarDayView({
created: t("notifications.event_created"), created: t("notifications.event_created"),
error: t("notifications.event_error"), error: t("notifications.event_error"),
}, },
isMobile,
}); });
const formatHour = (h: number): string => { const formatHour = (h: number): string => {
@@ -246,9 +247,11 @@ export function CalendarDayView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes); 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 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 calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100; const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100;
@@ -270,11 +273,21 @@ export function CalendarDayView({
onContextMenu={onContextMenuEvent} onContextMenu={onContextMenuEvent}
draggable 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 <div
data-resize-handle 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" 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")} aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)} onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove} onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp} onPointerUp={handleResizePointerUp}
> >
+16 -3
View File
@@ -183,6 +183,7 @@ export function CalendarWeekView({
created: t("notifications.event_created"), created: t("notifications.event_created"),
error: t("notifications.event_error"), error: t("notifications.event_error"),
}, },
isMobile,
}); });
const formatHour = (h: number): string => { const formatHour = (h: number): string => {
@@ -384,9 +385,11 @@ export function CalendarWeekView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes); 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 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 calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100; const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100;
@@ -408,11 +411,21 @@ export function CalendarWeekView({
onContextMenu={onContextMenuEvent} onContextMenu={onContextMenuEvent}
draggable 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 <div
data-resize-handle 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" 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")} aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)} onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove} onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp} onPointerUp={handleResizePointerUp}
> >
+21 -8
View File
@@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]); }, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setTimeout(() => { const trimmed = query.trim();
setShowSuggestions(false); if (trimmed && EMAIL_REGEX.test(trimmed)) {
const trimmed = query.trim(); addParticipant({ name: "", email: trimmed });
if (trimmed && EMAIL_REGEX.test(trimmed)) { }
addParticipant({ name: "", email: trimmed }); setTimeout(() => setShowSuggestions(false), 200);
}
}, 200);
}, [query, addParticipant]); }, [query, addParticipant]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
key={`${p.email}-${i}`} 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]" 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 && ( {!disabled && (
<button <button
type="button" type="button"
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => { it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn(); const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />); render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const deleteButton = screen.getAllByRole('button').find( fireEvent.click(screen.getByLabelText('detail.more_actions'));
btn => btn.className.includes('text-red') fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
);
fireEvent.click(deleteButton!);
expect(onDelete).toHaveBeenCalledOnce(); expect(onDelete).toHaveBeenCalledOnce();
}); });
}); });
@@ -36,7 +36,6 @@ const defaultProps = {
onSearchChange: vi.fn(), onSearchChange: vi.fn(),
onSelectContact: vi.fn(), onSelectContact: vi.fn(),
onCreateNew: vi.fn(), onCreateNew: vi.fn(),
categoryLabel: 'All Contacts',
selectedContactIds: new Set<string>(), selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(), onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(), onSelectRangeContacts: vi.fn(),
@@ -79,8 +78,4 @@ describe('ContactList', () => {
expect(screen.getByText('bulk.export')).toBeInTheDocument(); expect(screen.getByText('bulk.export')).toBeInTheDocument();
}); });
it('shows category label with count', () => {
render(<ContactList {...defaultProps} />);
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
});
}); });
+117 -91
View File
@@ -2,12 +2,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Mail, CalendarDays, Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-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"; import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
const EMAIL_LIMIT = 5; const EMAIL_LIMIT = 5;
@@ -43,14 +44,15 @@ function buildEmailFilter(addresses: string[]): Record<string, unknown> {
} }
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean { function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (!event.participants) return false; if (event.participants) {
for (const p of Object.values(event.participants)) { for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase(); const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true; if (email && addresses.has(email)) return true;
if (p.sendTo) { if (p.sendTo) {
for (const target of Object.values(p.sendTo)) { for (const target of Object.values(p.sendTo)) {
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null; const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true; if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
} }
} }
} }
@@ -62,6 +64,26 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
return false; 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) { export function ContactActivity({ contact }: ContactActivityProps) {
const t = useTranslations("contacts.activity"); const t = useTranslations("contacts.activity");
const format = useFormatter(); const format = useFormatter();
@@ -170,111 +192,115 @@ export function ContactActivity({ contact }: ContactActivityProps) {
: { year: "numeric", month: "short", day: "numeric" }); : { 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); const d = new Date(event.start);
if (isNaN(d.getTime())) return event.start; if (isNaN(d.getTime())) return "";
if (event.showWithoutTime) { return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
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",
});
}; };
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 ( return (
<> <div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
<ActivitySection icon={Mail} title={t("recent_emails")}> <Section title={t("recent_emails")}>
{emailsLoading ? ( {emailsLoading ? (
<LoadingRow /> <LoadingRow />
) : emailsError ? ( ) : 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 ? ( ) : !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) => ( <div className="-mx-2">
<button {emails.map((email) => {
key={email.id} const sender = getEmailSender(email);
type="button" return (
onClick={() => handleOpenEmail(email)} <button
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" key={email.id}
> type="button"
<div className="flex items-baseline justify-between gap-2"> onClick={() => handleOpenEmail(email)}
<span className="text-sm font-medium truncate"> className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
{email.subject || t("no_subject")} >
</span> <Avatar name={sender.name} email={sender.address} size="sm" />
<span className="text-xs text-muted-foreground flex-shrink-0"> <div className="flex-1 min-w-0">
{formatEmailDate(email.receivedAt)} <div className="flex items-baseline justify-between gap-2">
</span> <span className="text-sm font-medium truncate">
</div> {sender.name || t("unknown_sender")}
{email.preview && ( </span>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <span className="text-xs text-muted-foreground flex-shrink-0">
{email.preview} {formatEmailDate(email.receivedAt)}
</p> </span>
)} </div>
</button> <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 ? ( {eventsLoading ? (
<LoadingRow /> <LoadingRow />
) : eventsError ? ( ) : 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 ? ( ) : !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) => ( <div className="space-y-4">
<button {eventGroups.map(([dateKey, group]) => (
key={event.id} <div key={dateKey}>
type="button" <div className="text-xs font-medium text-muted-foreground mb-1.5">
onClick={() => handleOpenEvent(event)} {formatEventDateHeader(dateKey)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation" </div>
> <div className="-mx-2">
<div className="flex items-baseline justify-between gap-2"> {group.map((event) => (
<span className="text-sm font-medium truncate"> <button
{event.title || t("no_title")} key={event.id}
</span> type="button"
<span className="text-xs text-muted-foreground flex-shrink-0"> onClick={() => handleOpenEvent(event)}
{formatEventDate(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> >
<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> </div>
</button> ))}
)) </div>
)} )}
</ActivitySection> </Section>
</>
);
}
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>
</div> </div>
); );
} }
function LoadingRow() { function LoadingRow() {
return ( return (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
</div> </div>
); );
} }
+63 -16
View File
@@ -11,16 +11,25 @@ import {
Eye, Eye,
Pencil, Pencil,
Mail, Mail,
Phone,
ClipboardCopy, ClipboardCopy,
Download, Download,
Users, Users,
Trash2, Trash2,
Copy,
Printer,
} from "lucide-react"; } from "lucide-react";
import type { ContactCard } from "@/lib/jmap/types"; import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store"; import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact } from "./contact-export"; import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
function getContactPrimaryPhone(contact: ContactCard): string {
if (!contact.phones) return "";
return Object.values(contact.phones)[0]?.number || "";
}
interface Position { interface Position {
x: number; x: number;
y: number; y: number;
@@ -38,6 +47,7 @@ interface ContactContextMenuProps {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onAddToGroup: () => void; onAddToGroup: () => void;
onDuplicate?: () => void;
onBatchExport?: () => void; onBatchExport?: () => void;
onBatchAddToGroup?: () => void; onBatchAddToGroup?: () => void;
onBatchDelete?: () => void; onBatchDelete?: () => void;
@@ -55,12 +65,14 @@ export function ContactContextMenu({
onEdit, onEdit,
onDelete, onDelete,
onAddToGroup, onAddToGroup,
onDuplicate,
onBatchExport, onBatchExport,
onBatchAddToGroup, onBatchAddToGroup,
onBatchDelete, onBatchDelete,
}: ContactContextMenuProps) { }: ContactContextMenuProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const email = getContactPrimaryEmail(contact); const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const handle = (fn: () => void) => () => { const handle = (fn: () => void) => () => {
@@ -73,10 +85,15 @@ export function ContactContextMenu({
window.location.href = `mailto:${email}`; window.location.href = `mailto:${email}`;
}; };
const handleCopyEmail = async () => { const handleCall = () => {
if (!email) return; if (!phone) return;
window.location.href = `tel:${phone}`;
};
const handleCopy = async (value: string) => {
if (!value) return;
try { try {
await navigator.clipboard.writeText(email); await navigator.clipboard.writeText(value);
toast.success(t("detail.copied")); toast.success(t("detail.copied"));
} catch { } catch {
toast.error(t("detail.copy_failed")); toast.error(t("detail.copy_failed"));
@@ -88,6 +105,10 @@ export function ContactContextMenu({
toast.success(t("export.success", { count: 1 })); toast.success(t("export.success", { count: 1 }));
}; };
const handlePrint = () => {
printContact(contact);
};
if (showBatchActions) { if (showBatchActions) {
return ( return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}> <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}> <ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} /> <ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} /> <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{(email || phone) && <ContextMenuSeparator />}
{email && ( {email && (
<> <ContextMenuItem
<ContextMenuSeparator /> icon={Mail}
<ContextMenuItem label={t("context_menu.send_email")}
icon={Mail} onClick={handle(handleSendEmail)}
label={t("context_menu.send_email")} />
onClick={handle(handleSendEmail)} )}
/> {phone && (
<ContextMenuItem <ContextMenuItem
icon={ClipboardCopy} icon={Phone}
label={t("detail.copy_email")} label={t("context_menu.call")}
onClick={handle(handleCopyEmail)} 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 /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
@@ -143,11 +178,23 @@ export function ContactContextMenu({
label={t("context_menu.add_to_group")} label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)} onClick={handle(onAddToGroup)}
/> />
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
<ContextMenuItem <ContextMenuItem
icon={Download} icon={Download}
label={t("context_menu.export_vcard")} label={t("context_menu.export_vcard")}
onClick={handle(handleExport)} onClick={handle(handleExport)}
/> />
<ContextMenuItem
icon={Printer}
label={t("context_menu.print")}
onClick={handle(handlePrint)}
/>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
icon={Trash2} icon={Trash2}
+433 -292
View File
@@ -1,23 +1,37 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; 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 { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; 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 { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types"; import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store"; 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 { interface ContactDetailProps {
contact: ContactCard | null; contact: ContactCard | null;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
isMobile?: boolean; isMobile?: boolean;
className?: string; className?: string;
} }
@@ -27,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", "); 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 { 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 (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') { if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try { try {
@@ -41,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
return String(dateInput.utc); return String(dateInput.utc);
} }
const pd = dateInput as PartialDate; 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 monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = []; const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]); if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (day) parts.push(String(day)); if (pd.day) parts.push(String(pd.day));
if (year) parts.push(String(year)); if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput); return parts.join(' ') || String(dateInput);
} }
const dateStr = String(dateInput); const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) { if (dateStr.startsWith("--")) {
// Partial date without year
const parts = dateStr.substring(2).split("-"); const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10); const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined; const day = parts[1] ? parseInt(parts[1], 10) : undefined;
@@ -70,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr; 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 t = useTranslations("contacts");
const smimeStore = useSmimeStore(); const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map()); const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
@@ -88,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
try { try {
let derBytes: ArrayBuffer | string | null = null; let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) { if (key.uri.startsWith('data:')) {
// data URI - extract base64 content
const commaIdx = key.uri.indexOf(','); const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue; if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1); 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); for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer; derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) { } else if (key.uri.startsWith('-----BEGIN')) {
// PEM-encoded certificate inline
derBytes = key.uri; derBytes = key.uri;
} }
if (!derBytes) continue; if (!derBytes) continue;
@@ -128,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const name = getContactDisplayName(contact); const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(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 emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : []; const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : []; 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 hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined; 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 ( return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}> <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("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={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 min-w-0 flex-1">
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} /> <Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2> <h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && ( {hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p> <p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)} )}
{titleLine && ( {subtitleParts.length > 0 && (
<p className="text-sm text-muted-foreground truncate">{titleLine}</p> <p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
)}
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
)} )}
</div> </div>
</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"> <Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" /> <Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")} {t("form.edit_title")}
</Button> </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"> <MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
<Trash2 className="w-4 h-4" />
</Button>
</div> </div>
</div> </div>
</div> </div>
<div className="px-6 py-6"> <div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> {hasContactDetails && (
<ContactActivity contact={contact} /> <Section title={t("detail.section_contact")}>
<div className="space-y-3">
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
{emails.map((e, i) => ( {emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group"> <FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline"> <div className="flex items-center gap-2 group">
{e.address} <a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
</a> {e.address}
{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" />
</a> </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>
</div> </FieldRow>
))} ))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")} category="contact">
{phones.map((p, i) => { {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 ( return (
<div key={i} className="flex items-center gap-2 group"> <FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline"> <div className="flex items-center gap-2 group">
{p.number} <a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
</a> {p.number}
{p.contexts && <ContextBadge contexts={p.contexts} />} </a>
{featureStr && ( <RowActions>
<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")} />
)} </RowActions>
<CopyButton </div>
value={p.number} </FieldRow>
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>
); );
})} })}
</Section>
)}
{(roles.length > 0 || jobTitles.length > 1) && ( {addresses.map((a, i) => {
<Section icon={Briefcase} title={t("detail.titles")} category="work"> const lines: string[] = [];
{titles.map((tl, i) => ( if (a.full || a.fullAddress) {
<div key={i} className="text-sm flex items-center gap-2"> lines.push((a.full || a.fullAddress) as string);
<span>{tl.name}</span> } else if (a.components && a.components.length > 0) {
{tl.kind && ( const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span> if (joined) lines.push(joined);
)} } else {
</div> const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
))} .map(s => (typeof s === "string" ? s.trim() : ""))
</Section> .filter(Boolean) as string[];
)} lines.push(...parts);
}
{orgs.length > 0 && ( return (
<Section icon={Building} title={t("detail.organizations")} category="work"> <FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
{orgs.map((o, i) => ( <div className="text-sm space-y-0.5">
<div key={i} className="text-sm"> {lines.map((line, idx) => (
{o.name} <div key={idx}>{line}</div>
{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>
{a.timeZone && ( {a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div> <div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)} )}
</div> </div>
))} </FieldRow>
</div> );
</Section> })}
</div>
)}
{onlineServices.length > 0 && (
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => ( {onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group"> <FieldRow
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( key={`os${i}`}
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all"> icon={Globe}
{svc.user || svc.uri} label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
</a> >
) : ( <div className="flex items-center gap-2 group">
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span> {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.service && ( {svc.user || svc.uri}
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span> </a>
)} ) : (
{svc.contexts && <ContextBadge contexts={svc.contexts} />} <span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
<CopyButton )}
value={svc.user || svc.uri} <RowActions>
label={t("detail.copy_url")} <CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
successMsg={t("detail.copied")} </RowActions>
failMsg={t("detail.copy_failed")} </div>
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"} </FieldRow>
/>
</div>
))} ))}
</Section> </div>
)} </Section>
)}
{anniversaries.length > 0 && ( {hasWork && (
<Section icon={Cake} title={t("detail.anniversaries")} category="personal"> <Section title={t("detail.section_work")}>
{anniversaries.map((ann, i) => ( <div className="space-y-3">
<div key={i} className="flex items-center gap-2 text-sm"> {orgs.map((o, i) => (
<span>{formatDate(ann.date)}</span> <FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"> <div className="text-sm">
{t(`detail.anniversary_${ann.kind}`)} {o.name}
</span> {o.units && o.units.length > 0 && (
</div> <span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
</FieldRow>
))} ))}
</Section> {titles.map((tl, i) => (
)} <FieldRow
key={`tl${i}`}
{personalInfo.length > 0 && ( icon={Briefcase}
<Section icon={Heart} title={t("detail.personal_info")} category="personal"> label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
{personalInfo.map((pi, i) => ( >
<div key={i} className="flex items-center gap-2 text-sm"> <div className="text-sm">{tl.name}</div>
<span>{pi.value}</span> </FieldRow>
<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>
))} ))}
</Section> </div>
)} </Section>
)}
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && ( {hasPersonal && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal"> <Section title={t("detail.section_personal")}>
<div className="text-sm"> <div className="space-y-3">
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>} {anniversaries.map((ann, i) => {
{contact.speakToAs.pronouns && (() => { const years = getCompletedYears(ann.date);
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
return firstPronoun ? ( return (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span> <FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
) : null; <div className="text-sm">
})()} {formatDate(ann.date)}
</div> {years !== null && (
</Section> <span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)} )}
</div>
{preferredLanguages.length > 0 && ( </FieldRow>
<Section icon={Languages} title={t("detail.languages")} category="personal"> );
})}
{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) => ( {preferredLanguages.map((lang, i) => (
<div key={i} className="flex items-center gap-2 text-sm"> <FieldRow
<span>{lang.language}</span> key={`lg${i}`}
{lang.contexts && <ContextBadge contexts={lang.contexts} />} icon={Languages}
</div> 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 && ( {keywords.length > 0 && (
<Section icon={Tag} title={t("detail.categories")} category="digital"> <Section title={t("detail.categories")}>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => ( {keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary"> <span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw} {kw}
</span> </span>
))} ))}
</div> </div>
</Section> </Section>
)} )}
{relatedTo.length > 0 && ( {relatedTo.length > 0 && (
<Section icon={Users} title={t("detail.related_contacts")} category="personal"> <Section title={t("detail.related_contacts")}>
<div className="space-y-2">
{relatedTo.map(([uri, rel], i) => { {relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined; const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return ( return (
<div key={i} className="flex items-center gap-2 text-sm"> <FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
<span>{uri}</span> <div className="text-sm break-all">{uri}</div>
{relType && ( </FieldRow>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
)}
</div>
); );
})} })}
</Section> </div>
)} </Section>
)}
{cryptoKeys.length > 0 && ( {cryptoKeys.length > 0 && (
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital"> <Section title={t("detail.crypto_keys")}>
<div className="space-y-3">
{cryptoKeys.map((key, i) => { {cryptoKeys.map((key, i) => {
const certInfo = parsedCerts.get(i); const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false; const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
@@ -430,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
: false; : false;
return ( 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 ? ( {certInfo ? (
<> <>
<div className="flex items-center gap-2"> <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>} {certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div> </div>
{!alreadyImported && ( {!alreadyImported && (
<Button <Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
variant="ghost"
size="sm"
className="ml-4 mt-1"
onClick={() => handleImportContactCert(i)}
>
<Download className="w-3 h-3 mr-1" /> <Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")} {t("detail.import_to_smime")}
</Button> </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") ? ( {typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline"> <a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri} {key.uri}
@@ -479,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</div> </div>
); );
})} })}
</Section> </div>
)} </Section>
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && ( {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section icon={Calendar} title={t("detail.calendar")} category="calendar"> <Section title={t("detail.calendar")}>
<div className="space-y-3">
{contact.calendarUri && ( {contact.calendarUri && (
<div className="text-sm"> <FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span> <a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a> {contact.calendarUri}
</div> </a>
</FieldRow>
)} )}
{contact.schedulingUri && ( {contact.schedulingUri && (
<div className="text-sm"> <FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span> <a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a> {contact.schedulingUri}
</div> </a>
</FieldRow>
)} )}
{contact.freeBusyUri && ( {contact.freeBusyUri && (
<div className="text-sm"> <FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span> <a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a> {contact.freeBusyUri}
</div> </a>
</FieldRow>
)} )}
</Section> </div>
)} </Section>
)}
{/* Notes span full width */} {notes.length > 0 && (
{notes.length > 0 && ( <Section title={t("detail.notes")}>
<div className="md:col-span-2 xl:col-span-3"> <div className="flex items-start gap-3">
<Section icon={StickyNote} title={t("detail.notes")} category="notes"> <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) => ( {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> </div>
)} </Section>
)}
{/* Timestamps span full width */} <ContactActivity contact={contact} />
{(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 || contact.updated) && (
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>} <div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>} {contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
</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> )}
</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 }) { function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return ( return (
<button <button
@@ -575,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
toast.error(failMsg); 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} title={label}
aria-label={label} aria-label={label}
> >
+182 -65
View File
@@ -2,11 +2,12 @@
import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; 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 { interface EmailEntry {
address: string; address: string;
@@ -53,55 +54,73 @@ interface ContactFormProps {
onCancel: () => void; onCancel: () => void;
} }
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes"; function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
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" }: {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
collapsible?: boolean; collapsible?: boolean;
defaultOpen?: boolean; defaultOpen?: boolean;
category?: FormCategory;
}) { }) {
const [open, setOpen] = useState(defaultOpen || !collapsible); const [open, setOpen] = useState(defaultOpen);
return ( 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 <button
type="button" type="button"
className={cn( className={cn(
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors", "flex items-center gap-2 w-full text-left",
collapsible && "hover:text-muted-foreground cursor-pointer", collapsible ? "cursor-pointer" : "cursor-default"
!collapsible && "cursor-default"
)} )}
onClick={() => collapsible && setOpen(!open)} onClick={() => collapsible && setOpen(!open)}
tabIndex={collapsible ? 0 : -1} tabIndex={collapsible ? 0 : -1}
> >
<Icon className="w-4 h-4 text-muted-foreground shrink-0" /> <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 && ( {collapsible && (
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" /> open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)} )}
</button> </button>
{open && ( {(open || !collapsible) && (
<div className="space-y-3 pt-3 pb-1"> <div className="space-y-3 mt-3">
{children} {children}
</div> </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 }: { function Select({ value, onChange, children, className }: {
value: string; value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
@@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
}, [contact]); }, [contact]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId); 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 [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({}); 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 => { const validateEmail = (address: string): boolean => {
if (!address.trim()) return true; if (!address.trim()) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim()); 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> = { const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true }, name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
@@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
calendarUri: calendarUri.trim() || undefined, calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined, freeBusyUri: freeBusyUri.trim() || undefined,
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), ...(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 ( return (
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background"> <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"> <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>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-4"> <div className="px-6 py-4 max-w-3xl">
{error && ( {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"> <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} {error}
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="flex items-center gap-4 pb-4">
<button
{/* Address Book Selector */} type="button"
{addressBooks && addressBooks.length > 1 && ( onClick={() => photoInputRef.current?.click()}
<div className="md:col-span-2 xl:col-span-3"> disabled={photoUploading}
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact"> className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
<select title={t("upload_photo")}
value={selectedBookId} aria-label={t("upload_photo")}
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" <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> <Trash2 className="w-3 h-3" />
{addressBooks.map((book) => ( {t("remove_photo")}
<option key={book.id} value={book.id}> </button>
{book.accountName ? `${book.name} (${book.accountName})` : book.name} )}
</option>
))}
</select>
</FormSection>
</div> </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 */} <FormSection icon={User} title={t("section_identity")}>
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2"> <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label> <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>
</div> </div>
</FormSection> </FormSection>
</div>
{/* Email */} {/* Email */}
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact"> <FormSection icon={Mail} title={t("email")}>
<div className="space-y-2"> <div className="space-y-2">
{emails.map((entry, i) => ( {emails.map((entry, i) => (
<div key={i}> <div key={i}>
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Phone */} {/* Phone */}
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact"> <FormSection icon={Phone} title={t("phone")}>
<div className="space-y-2"> <div className="space-y-2">
{phones.map((entry, i) => ( {phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Work & Organization */} {/* 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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label> <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> </div>
</FormSection> </FormSection>
{/* Addresses - full width */} {/* Addresses */}
<div className="md:col-span-2 xl:col-span-3"> <FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
<div className="space-y-3"> <div className="space-y-3">
{addresses.map((addr, i) => ( {addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative"> <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> </Button>
</div> </div>
</FormSection> </FormSection>
</div>
{/* Online Services */} {/* 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"> <div className="space-y-2">
{onlineServices.map((svc, i) => ( {onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Anniversaries */} {/* 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"> <div className="space-y-2">
{anniversaries.map((ann, i) => ( {anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Personal Info */} {/* 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"> <div className="space-y-2">
{personalInfoEntries.map((pi, i) => ( {personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Categories */} {/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital"> <FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
<CategoryComboBox <CategoryComboBox
keywordsStr={keywordsStr} keywordsStr={keywordsStr}
onChange={setKeywordsStr} onChange={setKeywordsStr}
@@ -828,7 +947,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection> </FormSection>
{/* Gender */} {/* 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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label> <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> </FormSection>
{/* Calendar */} {/* 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 className="space-y-3">
<div> <div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label> <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> </div>
</FormSection> </FormSection>
{/* Notes - full width */} {/* Notes */}
<div className="md:col-span-2 xl:col-span-3"> <FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} 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" 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> </FormSection>
</div>
</div> </div>
</div> </div>
+409 -56
View File
@@ -1,18 +1,98 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react"; 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 { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item"; import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu"; import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store"; import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-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 { interface ContactListProps {
contacts: ContactCard[]; contacts: ContactCard[];
selectedContactId: string | null; selectedContactId: string | null;
@@ -20,7 +100,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void; onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void; onSelectContact: (id: string) => void;
onCreateNew: () => void; onCreateNew: () => void;
categoryLabel: string;
className?: string; className?: string;
selectedContactIds: Set<string>; selectedContactIds: Set<string>;
onToggleSelection: (id: string) => void; onToggleSelection: (id: string) => void;
@@ -42,7 +121,6 @@ export function ContactList({
onSearchChange, onSearchChange,
onSelectContact, onSelectContact,
onCreateNew, onCreateNew,
categoryLabel,
className, className,
selectedContactIds, selectedContactIds,
onToggleSelection, onToggleSelection,
@@ -57,31 +135,94 @@ export function ContactList({
onAddContactToGroup, onAddContactToGroup,
}: ContactListProps) { }: ContactListProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const locale = useLocale();
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>(); 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(() => { const filtered = useMemo(() => {
if (!searchQuery) return contacts; const lower = searchQuery.trim().toLowerCase();
const lower = searchQuery.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) => { 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 name = getContactDisplayName(c).toLowerCase();
const emails = c.emails if (name.includes(lower)) return true;
? Object.values(c.emails).map((e) => e.address.toLowerCase()) if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
: []; if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
const phones = c.phones if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "") return false;
: [];
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))
);
}); });
}, [contacts, searchQuery]); }, [contacts, searchQuery, filters]);
const sorted = useMemo(() => { const sorted = useMemo(() => {
return [...filtered].sort((a, b) => { return [...filtered].sort((a, b) => {
@@ -93,29 +234,214 @@ export function ContactList({
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]); 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 hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id)); const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return ( return (
<div className={cn("flex flex-col h-full", className)}> <div className={cn("flex flex-col h-full", className)}>
{/* Search header */} {/* Toolbar: select / search / filter */}
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}> <div className="border-b border-border bg-background">
<div className="flex items-center justify-between"> <div className="px-3 py-3">
<span className="text-xs font-medium text-muted-foreground truncate"> <div className="flex items-center gap-1.5">
{categoryLabel} ({contacts.length}) <button
</span> type="button"
</div> onClick={() => {
<div className="relative"> if (hasSelection) {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" /> if (allSelected) onClearSelection();
<Input else onSelectAll(sortedIds);
placeholder={t("search_placeholder")} } else if (sortedIds.length > 0) {
value={searchQuery} const target = selectedContactId && sortedIds.includes(selectedContactId)
onChange={(e) => onSearchChange(e.target.value)} ? selectedContactId
className="pl-8 h-8 text-sm" : 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>
</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 */} {/* Bulk action bar */}
{hasSelection && ( {hasSelection && (
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap"> <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"> <div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center"> <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" /> {activeFilters > 0 && !searchQuery ? (
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p> <Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p> ) : (
<Button <Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
variant="outline" )}
size="sm" <p className="text-sm font-medium text-foreground">
className="mt-3" {searchQuery ? t("empty_search") : t("empty_filtered")}
onClick={() => onSearchChange("")} </p>
> <p className="text-xs text-muted-foreground mt-1">
{t("clear_search")} {searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
</Button> </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>
) : ( ) : (
<div> (() => {
{sorted.map((contact) => ( const renderItem = (contact: ContactCard) => (
<ContactListItem <ContactListItem
key={contact.id} key={contact.id}
contact={contact} contact={contact}
@@ -221,8 +560,22 @@ export function ContactList({
}} }}
onContextMenu={(e, c) => openContextMenu(e, c)} 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> </div>
+120
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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, X,
AlertCircle, AlertCircle,
ChevronDown, ChevronDown,
ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl'; import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation'; import { useRouter } from '@/i18n/navigation';
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [showCalendarPicker, setShowCalendarPicker] = useState(false); const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>(''); const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown'); const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
const [isCollapsed, setIsCollapsed] = useState(true);
const attachment = findCalendarAttachment(email); const attachment = findCalendarAttachment(email);
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null; const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel'; const isCancellation = method === 'cancel';
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter'; const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
const canCollapse = method === 'reply';
const showDetails = !canCollapse || !isCollapsed;
const allowsRsvp = method === 'request'; const allowsRsvp = method === 'request';
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown'; 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> <span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
</div> </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> </div>
{/* Content */} {/* Content */}
{showDetails && (
<div className="px-4 py-3 space-y-2.5"> <div className="px-4 py-3 space-y-2.5">
<div className="lg:flex lg:gap-6"> <div className="lg:flex lg:gap-6">
{/* Left: Event info */} {/* Left: Event info */}
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div> </div>
)} )}
</div> </div>
)}
{/* Actions */} {/* Actions */}
{showDetails && (
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap"> <div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
{canRespond && ( {canRespond && (
<> <>
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" /> <Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
)} )}
</div> </div>
)}
</div> </div>
); );
} }
+3 -2
View File
@@ -99,7 +99,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
: "bg-background" : "bg-background"
), ),
selected && !colorTag && "shadow-sm", 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", colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10", isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state // Add visual feedback for checked state
@@ -302,7 +303,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")} backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
+6 -5
View File
@@ -69,6 +69,7 @@ export function EmailList({
batchMarkAsRead, batchMarkAsRead,
batchDelete, batchDelete,
batchMoveToMailbox, batchMoveToMailbox,
batchArchive,
batchMarkAsSpam, batchMarkAsSpam,
batchUndoSpam, batchUndoSpam,
loadMoreEmails, loadMoreEmails,
@@ -497,12 +498,12 @@ export function EmailList({
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)} onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => { onBatchArchive={async () => {
if (!onArchive) return; if (!client) return;
const selected = emails.filter((e) => selectedEmailIds.has(e.id)); try {
for (const email of selected) { await batchArchive(client);
await onArchive(email); } catch (error) {
console.error('Failed to batch archive:', error);
} }
clearSelection();
}} }}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => { onBatchMarkAsSpam={async () => {
+21 -10
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; 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 { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -2322,7 +2322,7 @@ export function EmailViewer({
let blockedExternalContent = false; let blockedExternalContent = false;
// Use shared sanitization config as base (more secure) // 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) // Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
@@ -2438,7 +2438,7 @@ export function EmailViewer({
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 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 }; return { html: cleanHtml, isHtml: true };
} }
if (smimeDecryptedText) { if (smimeDecryptedText) {
@@ -2446,7 +2446,7 @@ export function EmailViewer({
} }
// TNEF (winmail.dat) extracted content // TNEF (winmail.dat) extracted content
if (tnefHtml) { if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (tnefText) { if (tnefText) {
@@ -2454,7 +2454,7 @@ export function EmailViewer({
} }
// Embedded message/rfc822 unwrapped content // Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) { if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG); const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true }; return { html: cleanHtml, isHtml: true };
} }
if (embeddedEmailText) { if (embeddedEmailText) {
@@ -2603,7 +2603,7 @@ export function EmailViewer({
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style> <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; } img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; } a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; } table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -2880,7 +2880,7 @@ export function EmailViewer({
<> <>
{/* Left: Reply actions */} {/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -3628,7 +3628,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}> <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"> <div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */} {/* 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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -3676,8 +3676,8 @@ export function EmailViewer({
)} )}
</div> </div>
</div> </div>
{/* Date/time on the right of subject row */} {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="flex-shrink-0 text-right"> <div className="hidden sm:block flex-shrink-0 text-right">
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap"> <span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} {formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span> </span>
@@ -4484,6 +4484,17 @@ export function EmailViewer({
)} )}
</div> </div>
</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>
</div> </div>
+6 -4
View File
@@ -133,7 +133,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "bg-background" : "bg-background"
), ),
selected && !resolvedColorTag && "shadow-sm", 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", resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30", isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40", isChecked && "ring-2 ring-primary/20 bg-accent/40",
@@ -323,7 +324,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */} {/* Hover Quick Actions */}
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")} backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
@@ -487,7 +488,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "bg-background" : "bg-background"
), ),
isSelected && !colorTag && "shadow-sm", 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", colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30", hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50", isExpanded && "border-b border-border/50",
@@ -725,7 +727,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */} {/* Hover Quick Actions for thread header */}
<EmailHoverActions <EmailHoverActions
email={latestEmail} email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")} backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
@@ -7,22 +7,21 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game'; import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() { export function AboutDataSettings() {
const t = useTranslations('settings.advanced'); const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } = const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore(); useSettingsStore();
const { settingsSyncEnabled } = useConfig(); const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const { isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false); const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0); const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -86,7 +85,6 @@ export function AdvancedSettings() {
return ( return (
<> <>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />} {showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6"> <div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4"> <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"> <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>
</div> </div>
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */} {settingsSyncEnabled && (
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( <SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}> <ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} /> </SettingItem>
</SettingItem> )}
)}
{/* Debug Categories */} {isFeatureEnabled('settingsExportEnabled') && (
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( <SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1"> <Button variant="outline" size="sm" onClick={handleExport}>
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p> {t('export_settings.button')}
{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')}
</Button> </Button>
</> </SettingItem>
</SettingItem> )}
)}
{/* Reset Settings */} {isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}> <SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<Button <>
variant={showResetConfirm ? 'destructive' : 'outline'} <input
size="sm" ref={fileInputRef}
onClick={handleReset} type="file"
> accept="application/json,.json"
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')} onChange={handleFileChange}
</Button> className="hidden"
</SettingItem> />
</SettingsSection> <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>
</> </>
); );
} }
+7 -72
View File
@@ -2,15 +2,13 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store'; import { useSettingsStore, type Density } from '@/stores/settings-store';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider'; import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react'; import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; 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 }> = { 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 }, '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() { export function AppearanceSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour'); const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore(); 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 { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}> <SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup <RadioGroup
value={theme} value={theme}
@@ -88,12 +85,6 @@ export function AppearanceSettings() {
/> />
</SettingItem> </SettingItem>
{/* Language */}
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
{/* Font Size */}
{!isSettingHidden('fontSize') && ( {!isSettingHidden('fontSize') && (
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}> <SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
<RadioGroup <RadioGroup
@@ -108,7 +99,6 @@ export function AppearanceSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Density */}
{!isSettingHidden('density') && ( {!isSettingHidden('density') && (
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}> <SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
<RadioGroup <RadioGroup
@@ -127,64 +117,6 @@ export function AppearanceSettings() {
</SettingItem> </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') && ( {!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}> <SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch <ToggleSwitch
@@ -194,7 +126,10 @@ export function AppearanceSettings() {
</SettingItem> </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')}> <SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button <Button
variant="outline" variant="outline"
+118
View File
@@ -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>
);
}
+14 -1
View File
@@ -4,11 +4,12 @@ import { useState, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Upload, Download } from "lucide-react"; import { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
export function ContactsSettings() { export function ContactsSettings() {
@@ -20,6 +21,8 @@ export function ContactsSettings() {
supportsSync, supportsSync,
importContacts, importContacts,
} = useContactStore(); } = useContactStore();
const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group"); const individuals = contacts.filter(c => c.kind !== "group");
@@ -55,6 +58,16 @@ export function ContactsSettings() {
title={tSettings("title")} title={tSettings("title")}
description={tSettings("description")} 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 <SettingItem
label={tSettings("import_label")} label={tSettings("import_label")}
description={tSettings("import_description")} 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>
);
}
+51
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+162
View File
@@ -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"; "use client";
import { useState, useCallback } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; 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 { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [ export function ReadingSettings() {
{ 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() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [showTrustedModal, setShowTrustedModal] = useState(false);
const [isReorganizing, setIsReorganizing] = useState(false); const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null); const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); 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 { const {
markAsReadDelay, markAsReadDelay,
deleteAction, deleteAction,
@@ -120,33 +25,17 @@ export function EmailSettings() {
showPreview, showPreview,
mailLayout, mailLayout,
disableThreading, disableThreading,
autoSelectReplyIdentity,
plainTextMode, plainTextMode,
emailsPerPage, emailsPerPage,
externalContentPolicy,
mailAttachmentAction, mailAttachmentAction,
attachmentPosition, attachmentPosition,
emailAlwaysLightMode,
archiveMode, archiveMode,
hoverActions, hoverActions,
hoverActionsMode, hoverActionsMode,
hoverActionsCorner, hoverActionsCorner,
trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
hideInlineImageAttachments, hideInlineImageAttachments,
updateSetting, updateSetting,
} = useSettingsStore(); } = 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'; const isFocusedLayout = mailLayout === 'focus';
@@ -163,8 +52,6 @@ export function EmailSettings() {
try { try {
const archiveId = archiveMailbox.originalId || archiveMailbox.id; const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Fetch all emails in the root archive mailbox
const emails = await client.getEmailsInMailbox(archiveId); const emails = await client.getEmailsInMailbox(archiveId);
let movedCount = 0; let movedCount = 0;
@@ -173,10 +60,8 @@ export function EmailSettings() {
const year = emailDate.getFullYear().toString(); const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0'); 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; let currentMailboxes = useEmailStore.getState().mailboxes;
// Find or create year subfolder
let yearMailbox = currentMailboxes.find( let yearMailbox = currentMailboxes.find(
m => m.name === year && m.parentId === archiveId m => m.name === year && m.parentId === archiveId
); );
@@ -190,7 +75,6 @@ export function EmailSettings() {
await client.moveEmail(email.id, yearMailbox.id); await client.moveEmail(email.id, yearMailbox.id);
movedCount++; movedCount++;
} else { } else {
// month mode
const yearId = yearMailbox.originalId || yearMailbox.id; const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = currentMailboxes.find( let monthMailbox = currentMailboxes.find(
m => m.name === month && m.parentId === yearId m => m.name === month && m.parentId === yearId
@@ -215,7 +99,6 @@ export function EmailSettings() {
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
{!isSettingHidden('markAsReadDelay') && ( {!isSettingHidden('markAsReadDelay') && (
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}> <SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
<Select <Select
@@ -231,7 +114,6 @@ export function EmailSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Delete Action */}
{!isSettingHidden('deleteAction') && ( {!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}> <SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -253,7 +135,6 @@ export function EmailSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Archive Mode */}
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}> <SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Select <Select
@@ -287,7 +168,6 @@ export function EmailSettings() {
</div> </div>
</SettingItem> </SettingItem>
{/* Permanently Delete Junk */}
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}> <SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
<ToggleSwitch <ToggleSwitch
checked={permanentlyDeleteJunk} checked={permanentlyDeleteJunk}
@@ -295,23 +175,6 @@ export function EmailSettings() {
/> />
</SettingItem> </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') && ( {!isSettingHidden('showPreview') && (
<SettingItem <SettingItem
label={t('show_preview.label')} label={t('show_preview.label')}
@@ -322,7 +185,6 @@ export function EmailSettings() {
</SettingItem> </SettingItem>
)} )}
{/* Disable Thread Grouping */}
<SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}> <SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}>
<ToggleSwitch <ToggleSwitch
checked={disableThreading} checked={disableThreading}
@@ -330,7 +192,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Plain Text Mode */}
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}> <SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch <ToggleSwitch
checked={plainTextMode} checked={plainTextMode}
@@ -338,71 +199,6 @@ export function EmailSettings() {
/> />
</SettingItem> </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')}> <SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch <ToggleSwitch
checked={hideInlineImageAttachments} checked={hideInlineImageAttachments}
@@ -410,7 +206,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && ( {isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3"> <div className="py-3 border-b border-border space-y-3">
<div> <div>
@@ -443,7 +238,6 @@ export function EmailSettings() {
})} })}
</div> </div>
{/* Hover Actions Display Mode */}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label> <label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -465,7 +259,6 @@ export function EmailSettings() {
</div> </div>
</div> </div>
{/* Corner Selection (only when floating) */}
{hoverActionsMode === 'floating' && ( {hoverActionsMode === 'floating' && (
<div className="pt-1 space-y-2"> <div className="pt-1 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label> <label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label>
@@ -513,7 +306,6 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{/* Emails Per Page */}
{!isSettingHidden('emailsPerPage') && ( {!isSettingHidden('emailsPerPage') && (
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}> <SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
<Select <Select
@@ -528,75 +320,6 @@ export function EmailSettings() {
/> />
</SettingItem> </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> </SettingsSection>
); );
} }
+10 -14
View File
@@ -6,6 +6,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { getMailboxPath } from "@/lib/utils";
interface UseMailboxDropOptions { interface UseMailboxDropOptions {
mailbox: Mailbox; mailbox: Mailbox;
@@ -30,7 +31,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false); const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore(); const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore(); const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target // 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); const emailIds: string[] = JSON.parse(emailIdsJson);
// Get the destination mailbox ID (use originalId for shared folders) // Move in a single bulk JMAP request (store handles counter updates).
const destinationId = mailbox.originalId || mailbox.id; await moveEmailsToMailbox(client, emailIds, mailbox.id);
// Move emails one by one (store handles counter updates)
for (const emailId of emailIds) {
await moveToMailbox(client, emailId, destinationId);
}
// Clear selection if any selected emails were moved // Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) { if (emailIds.some(id => selectedEmailIds.has(id))) {
@@ -122,15 +118,15 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// Refresh the current mailbox view // Refresh the current mailbox view
await fetchEmails(client, selectedMailbox); await fetchEmails(client, selectedMailbox);
// Call success callback if provided, otherwise use fallback const mailboxPath = getMailboxPath(mailbox, mailboxes);
if (onSuccess) { if (onSuccess) {
onSuccess(emailIds.length, mailbox.name); onSuccess(emailIds.length, mailboxPath);
} else { } else {
// Fallback for backward compatibility
if (emailIds.length === 1) { if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`); toast.success("Email moved", `Moved to ${mailboxPath}`);
} else { } 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 { } finally {
endDrag(); 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(); const valid = isValidTarget();
+20 -16
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
// Tailwind v4 breakpoints // Tailwind v4 breakpoints
@@ -12,26 +12,30 @@ const BREAKPOINTS = {
"2xl": 1536, "2xl": 1536,
} as const; } as const;
const getMediaQueryServerSnapshot = () => false;
/** /**
* SSR-safe media query hook * SSR-safe media query hook. On SSR and the first hydration pass we report
* Returns false during SSR to prevent hydration mismatch * `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 { 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 getSnapshot = useCallback(
const mediaQuery = window.matchMedia(query); () => window.matchMedia(query).matches,
setMatches(mediaQuery.matches); [query],
);
const handler = (event: MediaQueryListEvent) => { return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
setMatches(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [query]);
return matches;
} }
/** /**
+63 -22
View File
@@ -1,9 +1,10 @@
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react"; 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 { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
interface DragCreateState { interface DragCreateState {
@@ -12,9 +13,13 @@ interface DragCreateState {
endMinutes: number; endMinutes: number;
} }
export type ResizeEdge = "top" | "bottom";
interface ResizeState { interface ResizeState {
eventId: string; eventId: string;
topPx: number;
heightPx: number; heightPx: number;
startMinutes: number;
durationMinutes: number; durationMinutes: number;
} }
@@ -40,6 +45,7 @@ interface UseTimeGridInteractionsOptions {
created: string; created: string;
error: string; error: string;
}; };
isMobile?: boolean;
} }
export function useTimeGridInteractions({ export function useTimeGridInteractions({
@@ -47,6 +53,7 @@ export function useTimeGridInteractions({
calendars, calendars,
onCreateRange, onCreateRange,
errorMessages, errorMessages,
isMobile,
}: UseTimeGridInteractionsOptions) { }: UseTimeGridInteractionsOptions) {
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => { const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
const raw = ((clientY - containerTop) / hourHeight) * 60; const raw = ((clientY - containerTop) / hourHeight) * 60;
@@ -72,6 +79,7 @@ export function useTimeGridInteractions({
dayKey: string, dayKey: string,
dayDate: Date, dayDate: Date,
) => { ) => {
if (isMobile) return;
if (e.button !== 0) return; if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) 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, dayKey, dayDate, startMinutes: minutes,
pointerId: e.pointerId, startY: e.clientY, captured: false, pointerId: e.pointerId, startY: e.clientY, captured: false,
}; };
}, [snapToMinutes]); }, [snapToMinutes, isMobile]);
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => { const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return; if (!dragRef.current) return;
@@ -130,43 +138,65 @@ export function useTimeGridInteractions({
// --- Resize --- // --- Resize ---
const resizeRef = useRef<{ const resizeRef = useRef<{
eventId: string; eventId: string;
edge: ResizeEdge;
startY: number; startY: number;
originalStartMinutes: number;
originalDurationMinutes: number; originalDurationMinutes: number;
originalHeightPx: number;
pointerId: number; pointerId: number;
} | null>(null); } | null>(null);
const [resizeVisual, setResizeVisual] = useState<ResizeState | 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(( const handleResizePointerDown = useCallback((
eventId: string, eventId: string,
edge: ResizeEdge,
originalStartMinutes: number,
originalDurationMinutes: number, originalDurationMinutes: number,
e: PointerEvent, e: PointerEvent,
) => { ) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
resizeRef.current = { resizeRef.current = {
eventId, eventId,
edge,
startY: e.clientY, startY: e.clientY,
originalStartMinutes,
originalDurationMinutes, originalDurationMinutes,
originalHeightPx,
pointerId: e.pointerId, pointerId: e.pointerId,
}; };
(e.target as HTMLElement).setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [hourHeight]); }, []);
const handleResizePointerMove = useCallback((e: PointerEvent) => { const handleResizePointerMove = useCallback((e: PointerEvent) => {
if (!resizeRef.current) return; if (!resizeRef.current) return;
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
const deltaY = e.clientY - resizeRef.current.startY; setResizeVisual({
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY); eventId: resizeRef.current.eventId,
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15); topPx: (startMinutes / 60) * hourHeight,
const snappedHeight = (newDurationMinutes / 60) * hourHeight; heightPx: (durationMinutes / 60) * hourHeight,
startMinutes,
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes }); durationMinutes,
}, [hourHeight]); });
}, [hourHeight, computeResize]);
const handleResizePointerUp = useCallback(async (e: PointerEvent) => { const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
const resize = resizeRef.current; const resize = resizeRef.current;
@@ -179,11 +209,11 @@ export function useTimeGridInteractions({
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ } try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
const deltaY = e.clientY - resize.startY; const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
if (newDurationMinutes === resize.originalDurationMinutes) { const startChanged = newStartMinutes !== resize.originalStartMinutes;
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
if (!startChanged && !durationChanged) {
setResizeVisual(null); setResizeVisual(null);
return; return;
} }
@@ -205,14 +235,21 @@ export function useTimeGridInteractions({
try { try {
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId); const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0; 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) { } catch (error) {
debug.error("Failed to resize event:", resize.eventId, error); debug.error("Failed to resize event:", resize.eventId, error);
toast.error(errorMessages.resize); toast.error(errorMessages.resize);
} finally { } finally {
setResizeVisual(null); setResizeVisual(null);
} }
}, [hourHeight, errorMessages.resize]); }, [computeResize, errorMessages.resize]);
// --- Click / Double-click / Quick-create --- // --- Click / Double-click / Quick-create ---
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -317,14 +354,18 @@ export function useTimeGridInteractions({
const minutes = snapDragMinutes(e); const minutes = snapDragMinutes(e);
const newStart = new Date(day); const newStart = new Date(day);
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0); 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; if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client; const client = useAuthStore.getState().client;
if (!client) { if (!client) {
toast.error(errorMessages.move); toast.error(errorMessages.move);
return; return;
} }
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0; const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined); await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch { } catch {
+38 -3
View File
@@ -219,10 +219,26 @@ export function layoutOverlappingEvents(
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes); return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
}); });
const columns: { event: CalendarEvent; end: number }[][] = [];
const result: TimedEventLayout[] = []; 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) { for (const event of sorted) {
if (columns.length > 0 && event.startMinutes >= clusterMaxEnd) {
flushCluster();
clusterStart = result.length;
columns = [];
clusterMaxEnd = 0;
}
let placed = false; let placed = false;
for (let col = 0; col < columns.length; col++) { for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= event.startMinutes)) { if (columns[col].every(e => e.end <= event.startMinutes)) {
@@ -236,10 +252,10 @@ export function layoutOverlappingEvents(
columns.push([{ event: event.event, end: event.endMinutes }]); columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ ...event, column: columns.length - 1, totalColumns: 0 }); result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
} }
clusterMaxEnd = Math.max(clusterMaxEnd, event.endMinutes);
} }
const total = columns.length; flushCluster();
result.forEach(r => r.totalColumns = total);
return result; return result;
} }
@@ -256,3 +272,22 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri
export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined { export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined {
return Object.keys(event.calendarIds || {})[0]; 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}`;
}
+29
View File
@@ -269,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); 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> { async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId); const email = this.data.emails.find(e => e.id === emailId);
if (email) email.mailboxIds = { [toMailboxId]: true }; if (email) email.mailboxIds = { [toMailboxId]: true };
+20
View File
@@ -36,6 +36,26 @@ export function sanitizeEmailHtml(html: string): string {
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG); 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 * Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources * Only allows basic formatting, no external resources
+7
View File
@@ -85,6 +85,13 @@ export interface IJMAPClient {
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>; batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: 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>; moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string): Promise<number>; emptyMailbox(mailboxId: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>; markAsSpam(emailId: string, accountId?: string): Promise<void>;
+114 -1
View File
@@ -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> { async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
const response = await this.request([ const response = await this.request([
@@ -1316,7 +1423,13 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1]; const result = response.methodResponses?.[0]?.[1];
if (result?.notCreated?.[createId]) { 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]; const created = result?.created?.[createId];
+19
View File
@@ -429,4 +429,23 @@ export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
traverse(nodes); traverse(nodes);
return result; 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);
} }
+72 -5
View File
@@ -671,14 +671,24 @@
"files": "Dateien", "files": "Dateien",
"contacts": "Kontakte", "contacts": "Kontakte",
"sidebar_apps": "Sidebar-Apps", "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": { "tab_groups": {
"general": "Allgemein", "general": "Allgemein",
"account": "Konto & Identität", "account": "Konto & Identität",
"organization": "E-Mail-Organisation", "organization": "E-Mail-Organisation",
"apps": "Apps", "apps": "Apps",
"system": "System" "system": "System",
"appearance": "Darstellung",
"mail": "E-Mail",
"privacy": "Datenschutz & Sicherheit",
"advanced": "Erweitert"
}, },
"appearance": { "appearance": {
"title": "Darstellung", "title": "Darstellung",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei", "empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
"empty_search": "Keine Kontakte gefunden", "empty_search": "Keine Kontakte gefunden",
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff", "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", "clear_search": "Suche löschen",
"import_vcard": "vCard importieren", "import_vcard": "vCard importieren",
"delete_confirm_title": "Kontakt löschen", "delete_confirm_title": "Kontakt löschen",
@@ -1814,7 +1826,22 @@
"import_to_smime": "In S/MIME importieren", "import_to_smime": "In S/MIME importieren",
"cert_already_imported": "Zertifikat bereits importiert", "cert_already_imported": "Zertifikat bereits importiert",
"cert_imported": "Zertifikat 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Neuer Kontakt", "create_title": "Neuer Kontakt",
@@ -1919,7 +1948,13 @@
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format", "email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden", "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": { "groups": {
"create": "Neue Gruppe", "create": "Neue Gruppe",
@@ -1979,6 +2014,37 @@
"error_create": "Kontakt konnte nicht erstellt werden", "error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert werden", "error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden" "error_delete": "Kontakt konnte nicht gelöscht werden"
},
"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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Vorläufig", "tentative": "Vorläufig",
"needs_action": "Antwort ausstehend", "needs_action": "Antwort ausstehend",
"remove": "Entfernen", "remove": "Entfernen",
"edit": "Bearbeiten",
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen", "email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
"send_invitations": "Einladungen an Teilnehmer senden", "send_invitations": "Einladungen an Teilnehmer senden",
"status_summary": "{accepted} zugesagt, {pending} ausstehend", "status_summary": "{accepted} zugesagt, {pending} ausstehend",
+70 -7
View File
@@ -426,7 +426,9 @@
"cancel_info": "The organizer has cancelled this event.", "cancel_info": "The organizer has cancelled this event.",
"event_updated": "Update #{sequence}", "event_updated": "Update #{sequence}",
"event_status_tentative": "Tentative", "event_status_tentative": "Tentative",
"event_status_cancelled": "Cancelled" "event_status_cancelled": "Cancelled",
"expand": "Show details",
"collapse": "Hide details"
}, },
"send": "Send", "send": "Send",
"more": "more" "more": "more"
@@ -671,14 +673,24 @@
"contacts": "Contacts", "contacts": "Contacts",
"encryption": "Encryption", "encryption": "Encryption",
"sidebar_apps": "Sidebar Apps", "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": { "tab_groups": {
"general": "General", "general": "General",
"account": "Account & Identity", "account": "Account & Identity",
"organization": "Mail Organization", "organization": "Mail Organization",
"apps": "Apps", "apps": "Apps",
"system": "System" "system": "System",
"appearance": "Appearance",
"mail": "Mail",
"privacy": "Privacy & Security",
"advanced": "Advanced"
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
@@ -1310,6 +1322,8 @@
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your 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_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file", "import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts", "export_label": "Export Contacts",
@@ -1737,6 +1751,8 @@
"empty_state_subtitle": "Create your first contact or import from a vCard file", "empty_state_subtitle": "Create your first contact or import from a vCard file",
"empty_search": "No contacts match your search", "empty_search": "No contacts match your search",
"empty_search_hint": "Try a different search term", "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", "clear_search": "Clear search",
"import_vcard": "Import vCard", "import_vcard": "Import vCard",
"delete_confirm_title": "Delete contact", "delete_confirm_title": "Delete contact",
@@ -1814,7 +1830,22 @@
"calendar": "Calendar", "calendar": "Calendar",
"calendar_uri": "Calendar URL", "calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1854,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "New Contact", "create_title": "New Contact",
@@ -1919,7 +1952,13 @@
"email_invalid": "Please enter a valid email address", "email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format", "email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact", "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": { "groups": {
"create": "New Group", "create": "New Group",
@@ -1986,7 +2025,30 @@
"send_email": "Send email", "send_email": "Send email",
"add_to_group": "Add to group", "add_to_group": "Add to group",
"export_vcard": "Export as vCard", "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": { "calendar": {
@@ -2062,6 +2124,7 @@
"tentative": "Tentative", "tentative": "Tentative",
"needs_action": "Needs action", "needs_action": "Needs action",
"remove": "Remove", "remove": "Remove",
"edit": "Edit",
"email_placeholder": "Add email address or search contacts", "email_placeholder": "Add email address or search contacts",
"send_invitations": "Send invitations to participants", "send_invitations": "Send invitations to participants",
"status_summary": "{accepted} accepted, {pending} pending", "status_summary": "{accepted} accepted, {pending} pending",
+72 -5
View File
@@ -671,14 +671,24 @@
"files": "Archivos", "files": "Archivos",
"contacts": "Contactos", "contacts": "Contactos",
"sidebar_apps": "Apps de barra lateral", "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": { "tab_groups": {
"general": "General", "general": "General",
"account": "Cuenta e identidad", "account": "Cuenta e identidad",
"organization": "Organización del correo", "organization": "Organización del correo",
"apps": "Aplicaciones", "apps": "Aplicaciones",
"system": "Sistema" "system": "Sistema",
"appearance": "Apariencia",
"mail": "Correo",
"privacy": "Privacidad y seguridad",
"advanced": "Avanzado"
}, },
"appearance": { "appearance": {
"title": "Apariencia", "title": "Apariencia",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard", "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": "Ningún contacto coincide con tu búsqueda",
"empty_search_hint": "Prueba con otro término de 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", "clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard", "import_vcard": "Importar vCard",
"delete_confirm_title": "Eliminar contacto", "delete_confirm_title": "Eliminar contacto",
@@ -1814,7 +1826,22 @@
"import_to_smime": "Importar a S/MIME", "import_to_smime": "Importar a S/MIME",
"cert_already_imported": "Certificado ya importado", "cert_already_imported": "Certificado ya importado",
"cert_imported": "Certificado 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nuevo contacto", "create_title": "Nuevo contacto",
@@ -1919,7 +1948,13 @@
"email_invalid": "Introduce una dirección de correo válida", "email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido", "email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto", "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": { "groups": {
"create": "Nuevo grupo", "create": "Nuevo grupo",
@@ -1979,6 +2014,37 @@
"error_create": "Error al crear el contacto", "error_create": "Error al crear el contacto",
"error_update": "Error al actualizar el contacto", "error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto" "error_delete": "Error al eliminar el contacto"
},
"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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Provisional", "tentative": "Provisional",
"needs_action": "Pendiente de respuesta", "needs_action": "Pendiente de respuesta",
"remove": "Eliminar", "remove": "Eliminar",
"edit": "Editar",
"email_placeholder": "Añadir dirección de correo o buscar contactos", "email_placeholder": "Añadir dirección de correo o buscar contactos",
"send_invitations": "Enviar invitaciones a los participantes", "send_invitations": "Enviar invitaciones a los participantes",
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)", "status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
+72 -5
View File
@@ -671,14 +671,24 @@
"files": "Fichiers", "files": "Fichiers",
"contacts": "Contacts", "contacts": "Contacts",
"sidebar_apps": "Apps de la barre latérale", "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": { "tab_groups": {
"general": "Général", "general": "Général",
"account": "Compte & Identité", "account": "Compte & Identité",
"organization": "Organisation des e-mails", "organization": "Organisation des e-mails",
"apps": "Applications", "apps": "Applications",
"system": "Système" "system": "Système",
"appearance": "Apparence",
"mail": "Courrier",
"privacy": "Confidentialité et sécurité",
"advanced": "Avancé"
}, },
"appearance": { "appearance": {
"title": "Apparence", "title": "Apparence",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard", "empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
"empty_search": "Aucun contact ne correspond à votre recherche", "empty_search": "Aucun contact ne correspond à votre recherche",
"empty_search_hint": "Essayez un autre terme de 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", "clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard", "import_vcard": "Importer vCard",
"delete_confirm_title": "Supprimer le contact", "delete_confirm_title": "Supprimer le contact",
@@ -1814,7 +1826,22 @@
"import_to_smime": "Importer dans S/MIME", "import_to_smime": "Importer dans S/MIME",
"cert_already_imported": "Certificat déjà importé", "cert_already_imported": "Certificat déjà importé",
"cert_imported": "Certificat 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nouveau contact", "create_title": "Nouveau contact",
@@ -1919,7 +1948,13 @@
"email_invalid": "Veuillez saisir une adresse e-mail valide", "email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide", "email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact", "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": "Limage est trop grande (max 10 Mo)",
"photo_invalid": "Fichier image invalide",
"change_photo": "Modifier"
}, },
"groups": { "groups": {
"create": "Nouveau groupe", "create": "Nouveau groupe",
@@ -1979,6 +2014,37 @@
"error_create": "Échec de la création du contact", "error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour du contact", "error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact" "error_delete": "Échec de la suppression du contact"
},
"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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Provisoire", "tentative": "Provisoire",
"needs_action": "En attente de réponse", "needs_action": "En attente de réponse",
"remove": "Retirer", "remove": "Retirer",
"edit": "Modifier",
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts", "email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
"send_invitations": "Envoyer les invitations aux participants", "send_invitations": "Envoyer les invitations aux participants",
"status_summary": "{accepted} accepté(s), {pending} en attente", "status_summary": "{accepted} accepté(s), {pending} en attente",
+72 -5
View File
@@ -671,14 +671,24 @@
"files": "File", "files": "File",
"contacts": "Contatti", "contacts": "Contatti",
"sidebar_apps": "App nella barra laterale", "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": { "tab_groups": {
"general": "Generale", "general": "Generale",
"account": "Account e identità", "account": "Account e identità",
"organization": "Organizzazione e-mail", "organization": "Organizzazione e-mail",
"apps": "Applicazioni", "apps": "Applicazioni",
"system": "Sistema" "system": "Sistema",
"appearance": "Aspetto",
"mail": "Posta",
"privacy": "Privacy e sicurezza",
"advanced": "Avanzate"
}, },
"appearance": { "appearance": {
"title": "Aspetto", "title": "Aspetto",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard", "empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
"empty_search": "Nessun contatto corrisponde alla ricerca", "empty_search": "Nessun contatto corrisponde alla ricerca",
"empty_search_hint": "Prova con un altro termine di 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", "clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard", "import_vcard": "Importa vCard",
"delete_confirm_title": "Elimina contatto", "delete_confirm_title": "Elimina contatto",
@@ -1814,7 +1826,22 @@
"import_to_smime": "Importa in S/MIME", "import_to_smime": "Importa in S/MIME",
"cert_already_imported": "Certificato già importato", "cert_already_imported": "Certificato già importato",
"cert_imported": "Certificato 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nuovo contatto", "create_title": "Nuovo contatto",
@@ -1919,7 +1948,13 @@
"email_invalid": "Inserisci un indirizzo email valido", "email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido", "email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto", "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": { "groups": {
"create": "Nuovo gruppo", "create": "Nuovo gruppo",
@@ -1979,6 +2014,37 @@
"error_create": "Impossibile creare il contatto", "error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare il contatto", "error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare 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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Provvisorio", "tentative": "Provvisorio",
"needs_action": "In attesa di risposta", "needs_action": "In attesa di risposta",
"remove": "Rimuovi", "remove": "Rimuovi",
"edit": "Modifica",
"email_placeholder": "Aggiungi indirizzo email o cerca contatti", "email_placeholder": "Aggiungi indirizzo email o cerca contatti",
"send_invitations": "Invia inviti ai partecipanti", "send_invitations": "Invia inviti ai partecipanti",
"status_summary": "{accepted} accettato/i, {pending} in attesa", "status_summary": "{accepted} accettato/i, {pending} in attesa",
+72 -5
View File
@@ -671,14 +671,24 @@
"files": "ファイル", "files": "ファイル",
"contacts": "連絡先", "contacts": "連絡先",
"sidebar_apps": "サイドバーアプリ", "sidebar_apps": "サイドバーアプリ",
"notifications": "通知" "notifications": "通知",
"layout": "レイアウト",
"reading": "閲覧",
"composing": "作成",
"content_senders": "コンテンツと送信者",
"about_data": "情報とデータ",
"debug": "デバッグ"
}, },
"tab_groups": { "tab_groups": {
"general": "一般", "general": "一般",
"account": "アカウントと身元", "account": "アカウントと身元",
"organization": "メール整理", "organization": "メール整理",
"apps": "アプリ", "apps": "アプリ",
"system": "システム" "system": "システム",
"appearance": "外観",
"mail": "メール",
"privacy": "プライバシーとセキュリティ",
"advanced": "詳細"
}, },
"appearance": { "appearance": {
"title": "外観", "title": "外観",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください", "empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
"empty_search": "検索に一致する連絡先がありません", "empty_search": "検索に一致する連絡先がありません",
"empty_search_hint": "別の検索語をお試しください", "empty_search_hint": "別の検索語をお試しください",
"empty_filtered": "フィルターに一致する連絡先がありません",
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
"clear_search": "検索をクリア", "clear_search": "検索をクリア",
"import_vcard": "vCardをインポート", "import_vcard": "vCardをインポート",
"delete_confirm_title": "連絡先を削除", "delete_confirm_title": "連絡先を削除",
@@ -1814,7 +1826,22 @@
"import_to_smime": "S/MIME にインポート", "import_to_smime": "S/MIME にインポート",
"cert_already_imported": "証明書はすでにインポートされています", "cert_already_imported": "証明書はすでにインポートされています",
"cert_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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "新しい連絡先", "create_title": "新しい連絡先",
@@ -1919,7 +1948,13 @@
"email_invalid": "有効なメールアドレスを入力してください", "email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません", "email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました", "save_failed": "連絡先の保存に失敗しました",
"delete": "削除" "delete": "削除",
"upload_photo": "写真をアップロード",
"remove_photo": "写真を削除",
"photo_hint": "JPGまたはPNG、最大10MB。リサイズされます。",
"photo_too_large": "画像が大きすぎます (最大10MB)",
"photo_invalid": "無効な画像ファイル",
"change_photo": "変更"
}, },
"groups": { "groups": {
"create": "新しいグループ", "create": "新しいグループ",
@@ -1979,6 +2014,37 @@
"error_create": "連絡先の作成に失敗しました", "error_create": "連絡先の作成に失敗しました",
"error_update": "連絡先の更新に失敗しました", "error_update": "連絡先の更新に失敗しました",
"error_delete": "連絡先の削除に失敗しました" "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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "仮承諾", "tentative": "仮承諾",
"needs_action": "未回答", "needs_action": "未回答",
"remove": "削除", "remove": "削除",
"edit": "編集",
"email_placeholder": "メールアドレスを追加または連絡先を検索", "email_placeholder": "メールアドレスを追加または連絡先を検索",
"send_invitations": "参加者に招待を送信", "send_invitations": "参加者に招待を送信",
"status_summary": "{accepted}人承諾、{pending}人保留", "status_summary": "{accepted}人承諾、{pending}人保留",
+72 -5
View File
@@ -671,14 +671,24 @@
"contacts": "연락처", "contacts": "연락처",
"encryption": "암호화", "encryption": "암호화",
"sidebar_apps": "사이드바 앱", "sidebar_apps": "사이드바 앱",
"notifications": "알림" "notifications": "알림",
"layout": "레이아웃",
"reading": "읽기",
"composing": "작성",
"content_senders": "콘텐츠 및 발신자",
"about_data": "정보 및 데이터",
"debug": "디버그"
}, },
"tab_groups": { "tab_groups": {
"general": "일반", "general": "일반",
"account": "계정 및 인증", "account": "계정 및 인증",
"organization": "메일 정리", "organization": "메일 정리",
"apps": "앱", "apps": "앱",
"system": "시스템" "system": "시스템",
"appearance": "모양",
"mail": "메일",
"privacy": "개인정보 및 보안",
"advanced": "고급"
}, },
"appearance": { "appearance": {
"title": "화면 설정", "title": "화면 설정",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요", "empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
"empty_search": "검색된 연락처가 없어요", "empty_search": "검색된 연락처가 없어요",
"empty_search_hint": "다른 검색어로 다시 시도해 보세요", "empty_search_hint": "다른 검색어로 다시 시도해 보세요",
"empty_filtered": "필터에 일치하는 연락처가 없어요",
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
"clear_search": "검색 지우기", "clear_search": "검색 지우기",
"import_vcard": "vCard 가져오기", "import_vcard": "vCard 가져오기",
"delete_confirm_title": "연락처 삭제", "delete_confirm_title": "연락처 삭제",
@@ -1814,7 +1826,22 @@
"calendar": "캘린더", "calendar": "캘린더",
"calendar_uri": "캘린더 URL", "calendar_uri": "캘린더 URL",
"scheduling_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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "새 연락처", "create_title": "새 연락처",
@@ -1919,7 +1948,13 @@
"email_invalid": "올바른 이메일 주소를 입력해 주세요", "email_invalid": "올바른 이메일 주소를 입력해 주세요",
"email_error_inline": "이메일 형식이 잘못되었어요", "email_error_inline": "이메일 형식이 잘못되었어요",
"save_failed": "연락처를 저장하지 못했어요", "save_failed": "연락처를 저장하지 못했어요",
"delete": "삭제" "delete": "삭제",
"upload_photo": "사진 업로드",
"remove_photo": "사진 제거",
"photo_hint": "JPG 또는 PNG, 최도 10MB. 크기가 조정됩니다.",
"photo_too_large": "이미지가 너무 큽니다 (최대 10MB)",
"photo_invalid": "잘못된 이미지 파일",
"change_photo": "변경"
}, },
"groups": { "groups": {
"create": "새 그룹", "create": "새 그룹",
@@ -1979,6 +2014,37 @@
"error_create": "연락처를 만들지 못했어요", "error_create": "연락처를 만들지 못했어요",
"error_update": "연락처를 업데이트하지 못했어요", "error_update": "연락처를 업데이트하지 못했어요",
"error_delete": "연락처를 삭제하지 못했어요" "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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "미정", "tentative": "미정",
"needs_action": "응답 필요", "needs_action": "응답 필요",
"remove": "삭제", "remove": "삭제",
"edit": "편집",
"email_placeholder": "이메일을 추가하거나 연락처를 검색하세요", "email_placeholder": "이메일을 추가하거나 연락처를 검색하세요",
"send_invitations": "참석자에게 초대장 보내기", "send_invitations": "참석자에게 초대장 보내기",
"status_summary": "{accepted}명 수락, {pending}명 대기 중", "status_summary": "{accepted}명 수락, {pending}명 대기 중",
+73 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Kontakti", "contacts": "Kontakti",
"encryption": "Šifrēšana", "encryption": "Šifrēšana",
"sidebar_apps": "Sānu joslas lietotnes", "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": { "tab_groups": {
"general": "Vispārīgi", "general": "Vispārīgi",
"account": "Konts un identitāte", "account": "Konts un identitāte",
"organization": "Pasta organizēšana", "organization": "Pasta organizēšana",
"apps": "Lietotnes", "apps": "Lietotnes",
"system": "Sistēma" "system": "Sistēma",
"appearance": "Izskats",
"mail": "Pasts",
"privacy": "Privātums un drošība",
"advanced": "Papildu"
}, },
"appearance": { "appearance": {
"title": "Izskats", "title": "Izskats",
@@ -1733,6 +1743,8 @@
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila", "empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam", "empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu", "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", "clear_search": "Notīrīt meklēšanu",
"import_vcard": "vCard imports", "import_vcard": "vCard imports",
"delete_confirm_title": "Dzēst kontaktu", "delete_confirm_title": "Dzēst kontaktu",
@@ -1810,7 +1822,22 @@
"calendar": "Kalendārs", "calendar": "Kalendārs",
"calendar_uri": "Kalendāra URL", "calendar_uri": "Kalendāra URL",
"scheduling_uri": "Plānošanas 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1819,7 +1846,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Jauns kontakts", "create_title": "Jauns kontakts",
@@ -1915,7 +1944,13 @@
"email_invalid": "Ievadiet derīgu e-pasta adresi", "email_invalid": "Ievadiet derīgu e-pasta adresi",
"email_error_inline": "Nederīgs e-pasta formāts", "email_error_inline": "Nederīgs e-pasta formāts",
"save_failed": "Neizdevās saglabāt kontaktu", "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": { "groups": {
"create": "Jauna grupa", "create": "Jauna grupa",
@@ -1979,7 +2014,38 @@
"rename_category": "Pārdēvēt kategoriju", "rename_category": "Pārdēvēt kategoriju",
"category_name_label": "Kategorijas nosaukums", "category_name_label": "Kategorijas nosaukums",
"category_renamed": "Kategorija pārdēvēta", "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": { "calendar": {
"title": "Kalendārs", "title": "Kalendārs",
@@ -2053,6 +2119,7 @@
"tentative": "Pagaidām", "tentative": "Pagaidām",
"needs_action": "Nepieciešama atbilde", "needs_action": "Nepieciešama atbilde",
"remove": "Noņemt", "remove": "Noņemt",
"edit": "Rediģēt",
"email_placeholder": "Pievienojiet adresi vai kontaktu", "email_placeholder": "Pievienojiet adresi vai kontaktu",
"send_invitations": "Sūtīt uzaicinājumus dalībniekiem", "send_invitations": "Sūtīt uzaicinājumus dalībniekiem",
"status_summary": "{accepted} pieņēmuši, {pending} gaida", "status_summary": "{accepted} pieņēmuši, {pending} gaida",
+72 -5
View File
@@ -671,14 +671,24 @@
"files": "Bestanden", "files": "Bestanden",
"contacts": "Contacten", "contacts": "Contacten",
"sidebar_apps": "Zijbalk-apps", "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": { "tab_groups": {
"general": "Algemeen", "general": "Algemeen",
"account": "Account & identiteit", "account": "Account & identiteit",
"organization": "E-mailorganisatie", "organization": "E-mailorganisatie",
"apps": "Apps", "apps": "Apps",
"system": "Systeem" "system": "Systeem",
"appearance": "Weergave",
"mail": "E-mail",
"privacy": "Privacy en beveiliging",
"advanced": "Geavanceerd"
}, },
"appearance": { "appearance": {
"title": "Uiterlijk", "title": "Uiterlijk",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand", "empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
"empty_search": "Geen contacten gevonden", "empty_search": "Geen contacten gevonden",
"empty_search_hint": "Probeer een andere zoekterm", "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", "clear_search": "Zoekopdracht wissen",
"import_vcard": "vCard importeren", "import_vcard": "vCard importeren",
"delete_confirm_title": "Contact verwijderen", "delete_confirm_title": "Contact verwijderen",
@@ -1814,7 +1826,22 @@
"import_to_smime": "Importeren naar S/MIME", "import_to_smime": "Importeren naar S/MIME",
"cert_already_imported": "Certificaat is al geïmporteerd", "cert_already_imported": "Certificaat is al geïmporteerd",
"cert_imported": "Certificaat 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nieuw contact", "create_title": "Nieuw contact",
@@ -1919,7 +1948,13 @@
"email_invalid": "Voer een geldig e-mailadres in", "email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat", "email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan", "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": { "groups": {
"create": "Nieuwe groep", "create": "Nieuwe groep",
@@ -1979,6 +2014,37 @@
"error_create": "Kon contact niet aanmaken", "error_create": "Kon contact niet aanmaken",
"error_update": "Kon contact niet bijwerken", "error_update": "Kon contact niet bijwerken",
"error_delete": "Kon contact niet verwijderen" "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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Voorlopig", "tentative": "Voorlopig",
"needs_action": "Reactie vereist", "needs_action": "Reactie vereist",
"remove": "Verwijderen", "remove": "Verwijderen",
"edit": "Bewerken",
"email_placeholder": "E-mailadres toevoegen of contacten zoeken", "email_placeholder": "E-mailadres toevoegen of contacten zoeken",
"send_invitations": "Uitnodigingen sturen naar deelnemers", "send_invitations": "Uitnodigingen sturen naar deelnemers",
"status_summary": "{accepted} geaccepteerd, {pending} in afwachting", "status_summary": "{accepted} geaccepteerd, {pending} in afwachting",
+72 -5
View File
@@ -671,14 +671,24 @@
"contacts": "Kontakty", "contacts": "Kontakty",
"encryption": "Szyfrowanie", "encryption": "Szyfrowanie",
"sidebar_apps": "Aplikacje paska bocznego", "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": { "tab_groups": {
"general": "Ogólne", "general": "Ogólne",
"account": "Konto i tożsamość", "account": "Konto i tożsamość",
"organization": "Organizacja poczty", "organization": "Organizacja poczty",
"apps": "Aplikacje", "apps": "Aplikacje",
"system": "System" "system": "System",
"appearance": "Wygląd",
"mail": "Poczta",
"privacy": "Prywatność i bezpieczeństwo",
"advanced": "Zaawansowane"
}, },
"appearance": { "appearance": {
"title": "Wygląd", "title": "Wygląd",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard", "empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
"empty_search": "Żadne kontakty nie pasują do wyszukiwania", "empty_search": "Żadne kontakty nie pasują do wyszukiwania",
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła", "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", "clear_search": "Wyczyść wyszukiwanie",
"import_vcard": "Importuj vCard", "import_vcard": "Importuj vCard",
"delete_confirm_title": "Usuń kontakt", "delete_confirm_title": "Usuń kontakt",
@@ -1814,7 +1826,22 @@
"calendar": "Kalendarz", "calendar": "Kalendarz",
"calendar_uri": "Adres URL kalendarza", "calendar_uri": "Adres URL kalendarza",
"scheduling_uri": "Adres URL planowania", "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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Nowy kontakt", "create_title": "Nowy kontakt",
@@ -1919,7 +1948,13 @@
"email_invalid": "Wprowadź prawidłowy adres e-mail", "email_invalid": "Wprowadź prawidłowy adres e-mail",
"email_error_inline": "Nieprawidłowy format adresu e-mail", "email_error_inline": "Nieprawidłowy format adresu e-mail",
"save_failed": "Nie udało się zapisać kontaktu", "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": { "groups": {
"create": "Nowa grupa", "create": "Nowa grupa",
@@ -1979,6 +2014,37 @@
"error_create": "Nie udało się utworzyć kontaktu", "error_create": "Nie udało się utworzyć kontaktu",
"error_update": "Nie udało się zaktualizować kontaktu", "error_update": "Nie udało się zaktualizować kontaktu",
"error_delete": "Nie udało się usunąć 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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Wstępnie", "tentative": "Wstępnie",
"needs_action": "Wymaga działania", "needs_action": "Wymaga działania",
"remove": "Usuń", "remove": "Usuń",
"edit": "Edytuj",
"email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty", "email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty",
"send_invitations": "Wyślij zaproszenia do uczestników", "send_invitations": "Wyślij zaproszenia do uczestników",
"status_summary": "{accepted} zaakceptowano, {pending} oczekuje", "status_summary": "{accepted} zaakceptowano, {pending} oczekuje",
+72 -5
View File
@@ -671,14 +671,24 @@
"files": "Arquivos", "files": "Arquivos",
"contacts": "Contactos", "contacts": "Contactos",
"sidebar_apps": "Apps da barra lateral", "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": { "tab_groups": {
"general": "Geral", "general": "Geral",
"account": "Conta e identidade", "account": "Conta e identidade",
"organization": "Organização de e-mail", "organization": "Organização de e-mail",
"apps": "Aplicativos", "apps": "Aplicativos",
"system": "Sistema" "system": "Sistema",
"appearance": "Aparência",
"mail": "Correio",
"privacy": "Privacidade e segurança",
"advanced": "Avançado"
}, },
"appearance": { "appearance": {
"title": "Aparência", "title": "Aparência",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard", "empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
"empty_search": "Nenhum contato encontrado", "empty_search": "Nenhum contato encontrado",
"empty_search_hint": "Tente outro termo de pesquisa", "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", "clear_search": "Limpar pesquisa",
"import_vcard": "Importar vCard", "import_vcard": "Importar vCard",
"delete_confirm_title": "Excluir contato", "delete_confirm_title": "Excluir contato",
@@ -1814,7 +1826,22 @@
"import_to_smime": "Importar para S/MIME", "import_to_smime": "Importar para S/MIME",
"cert_already_imported": "Certificado já importado", "cert_already_imported": "Certificado já importado",
"cert_imported": "Certificado 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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Novo contato", "create_title": "Novo contato",
@@ -1919,7 +1948,13 @@
"email_invalid": "Por favor, insira um endereço de e-mail válido", "email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido", "email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato", "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": { "groups": {
"create": "Novo grupo", "create": "Novo grupo",
@@ -1979,6 +2014,37 @@
"error_create": "Falha ao criar contato", "error_create": "Falha ao criar contato",
"error_update": "Falha ao atualizar contato", "error_update": "Falha ao atualizar contato",
"error_delete": "Falha ao excluir 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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Provisório", "tentative": "Provisório",
"needs_action": "Aguardando resposta", "needs_action": "Aguardando resposta",
"remove": "Remover", "remove": "Remover",
"edit": "Editar",
"email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos", "email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos",
"send_invitations": "Enviar convites aos participantes", "send_invitations": "Enviar convites aos participantes",
"status_summary": "{accepted} aceito(s), {pending} pendente(s)", "status_summary": "{accepted} aceito(s), {pending} pendente(s)",
+73 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Контакты", "contacts": "Контакты",
"encryption": "Шифрование", "encryption": "Шифрование",
"sidebar_apps": "Приложения боковой панели", "sidebar_apps": "Приложения боковой панели",
"notifications": "Уведомления" "notifications": "Уведомления",
"layout": "Макет",
"reading": "Чтение",
"composing": "Написание",
"content_senders": "Содержимое и отправители",
"about_data": "О программе и данные",
"debug": "Отладка"
}, },
"tab_groups": { "tab_groups": {
"general": "Общие", "general": "Общие",
"account": "Аккаунт и удостоверение", "account": "Аккаунт и удостоверение",
"organization": "Организация почты", "organization": "Организация почты",
"apps": "Приложения", "apps": "Приложения",
"system": "Система" "system": "Система",
"appearance": "Внешний вид",
"mail": "Почта",
"privacy": "Конфиденциальность и безопасность",
"advanced": "Дополнительно"
}, },
"appearance": { "appearance": {
"title": "Внешний вид", "title": "Внешний вид",
@@ -1117,7 +1127,7 @@
"description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую", "description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
"name_label": "Название ключа", "name_label": "Название ключа",
"name_placeholder": "напр. Скрипт резервного копирования, CI runner", "name_placeholder": "напр. Скрипт резервного копирования, CI runner",
"copy_now_warning": "Скопируйте этот API-ключ сейчас он больше не будет показан.", "copy_now_warning": "Скопируйте этот API-ключ сейчас он больше не будет показан.",
"added": "API-ключ создан", "added": "API-ключ создан",
"removed": "API-ключ удалён", "removed": "API-ключ удалён",
"add_error": "Не удалось создать API-ключ", "add_error": "Не удалось создать API-ключ",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard", "empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
"empty_search": "Нет контактов, соответствующих вашему запросу", "empty_search": "Нет контактов, соответствующих вашему запросу",
"empty_search_hint": "Попробуйте другой поисковый запрос", "empty_search_hint": "Попробуйте другой поисковый запрос",
"empty_filtered": "Нет контактов, соответствующих фильтрам",
"empty_filtered_hint": "Измените или очистите фильтры",
"clear_search": "Очистить поиск", "clear_search": "Очистить поиск",
"import_vcard": "Импорт vCard", "import_vcard": "Импорт vCard",
"delete_confirm_title": "Удалить контакт", "delete_confirm_title": "Удалить контакт",
@@ -1814,7 +1826,22 @@
"calendar": "Календарь", "calendar": "Календарь",
"calendar_uri": "URL календаря", "calendar_uri": "URL календаря",
"scheduling_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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Новый контакт", "create_title": "Новый контакт",
@@ -1919,7 +1948,13 @@
"email_invalid": "Введите корректный адрес электронной почты", "email_invalid": "Введите корректный адрес электронной почты",
"email_error_inline": "Неверный формат email", "email_error_inline": "Неверный формат email",
"save_failed": "Не удалось сохранить контакт", "save_failed": "Не удалось сохранить контакт",
"delete": "Удалить" "delete": "Удалить",
"upload_photo": "Загрузить фото",
"remove_photo": "Удалить фото",
"photo_hint": "JPG или PNG, до 10 МБ. Будет уменьшено.",
"photo_too_large": "Изображение слишком большое (макс. 10 МБ)",
"photo_invalid": "Недопустимый файл изображения",
"change_photo": "Изменить"
}, },
"groups": { "groups": {
"create": "Новая группа", "create": "Новая группа",
@@ -1979,6 +2014,37 @@
"error_create": "Не удалось создать контакт", "error_create": "Не удалось создать контакт",
"error_update": "Не удалось обновить контакт", "error_update": "Не удалось обновить контакт",
"error_delete": "Не удалось удалить контакт" "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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Предварительно", "tentative": "Предварительно",
"needs_action": "Требует ответа", "needs_action": "Требует ответа",
"remove": "Удалить", "remove": "Удалить",
"edit": "Редактировать",
"email_placeholder": "Добавьте адрес или найдите контакт", "email_placeholder": "Добавьте адрес или найдите контакт",
"send_invitations": "Отправить приглашения участникам", "send_invitations": "Отправить приглашения участникам",
"status_summary": "{accepted} принято, {pending} ожидает", "status_summary": "{accepted} принято, {pending} ожидает",
+73 -6
View File
@@ -671,14 +671,24 @@
"contacts": "Контакти", "contacts": "Контакти",
"encryption": "Шифрування", "encryption": "Шифрування",
"sidebar_apps": "Програми бічної панелі", "sidebar_apps": "Програми бічної панелі",
"notifications": "Сповіщення" "notifications": "Сповіщення",
"layout": "Макет",
"reading": "Читання",
"composing": "Написання",
"content_senders": "Вміст і відправники",
"about_data": "Про програму та дані",
"debug": "Налагодження"
}, },
"tab_groups": { "tab_groups": {
"general": "Загальний", "general": "Загальний",
"account": "Обліковий запис і ідентифікатор", "account": "Обліковий запис і ідентифікатор",
"organization": "Організація пошти", "organization": "Організація пошти",
"apps": "програми", "apps": "програми",
"system": "система" "system": "система",
"appearance": "Вигляд",
"mail": "Пошта",
"privacy": "Конфіденційність і безпека",
"advanced": "Додатково"
}, },
"appearance": { "appearance": {
"title": "Зовнішній вигляд", "title": "Зовнішній вигляд",
@@ -1117,7 +1127,7 @@
"description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму", "description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
"name_label": "Назва ключа", "name_label": "Назва ключа",
"name_placeholder": "напр. Скрипт резервної копії, CI runner", "name_placeholder": "напр. Скрипт резервної копії, CI runner",
"copy_now_warning": "Скопіюйте цей API-ключ зараз він більше не буде показаний.", "copy_now_warning": "Скопіюйте цей API-ключ зараз він більше не буде показаний.",
"added": "API-ключ створено", "added": "API-ключ створено",
"removed": "API-ключ видалено", "removed": "API-ключ видалено",
"add_error": "Не вдалося створити API-ключ", "add_error": "Не вдалося створити API-ключ",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard", "empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
"empty_search": "Жоден контакт не відповідає вашому пошуку", "empty_search": "Жоден контакт не відповідає вашому пошуку",
"empty_search_hint": "Спробуйте інший термін пошуку", "empty_search_hint": "Спробуйте інший термін пошуку",
"empty_filtered": "Жоден контакт не відповідає фільтрам",
"empty_filtered_hint": "Змініть або очистіть фільтри",
"clear_search": "Очистити пошук", "clear_search": "Очистити пошук",
"import_vcard": "Імпорт vCard", "import_vcard": "Імпорт vCard",
"delete_confirm_title": "Видалити контакт", "delete_confirm_title": "Видалити контакт",
@@ -1814,7 +1826,22 @@
"calendar": "Календар", "calendar": "Календар",
"calendar_uri": "URL-адреса календаря", "calendar_uri": "URL-адреса календаря",
"scheduling_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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "Новий контакт", "create_title": "Новий контакт",
@@ -1919,7 +1948,13 @@
"email_invalid": "Введіть дійсну електронну адресу", "email_invalid": "Введіть дійсну електронну адресу",
"email_error_inline": "Недійсний формат електронної пошти", "email_error_inline": "Недійсний формат електронної пошти",
"save_failed": "Не вдалося зберегти контакт", "save_failed": "Не вдалося зберегти контакт",
"delete": "Видалити" "delete": "Видалити",
"upload_photo": "Завантажити фото",
"remove_photo": "Видалити фото",
"photo_hint": "JPG або PNG, до 10 МБ. Буде зменшено.",
"photo_too_large": "Зображення завелике (макс. 10 МБ)",
"photo_invalid": "Недійсний файл зображення",
"change_photo": "Змінити"
}, },
"groups": { "groups": {
"create": "Нова група", "create": "Нова група",
@@ -1979,6 +2014,37 @@
"error_create": "Не вдалося створити контакт", "error_create": "Не вдалося створити контакт",
"error_update": "Не вдалося оновити контакт", "error_update": "Не вдалося оновити контакт",
"error_delete": "Не вдалося видалити контакт" "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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "Орієнтовний", "tentative": "Орієнтовний",
"needs_action": "Потребує дії", "needs_action": "Потребує дії",
"remove": "видалити", "remove": "видалити",
"edit": "Редагувати",
"email_placeholder": "Додайте електронну адресу або знайдіть контакти", "email_placeholder": "Додайте електронну адресу або знайдіть контакти",
"send_invitations": "Надішліть запрошення учасникам", "send_invitations": "Надішліть запрошення учасникам",
"status_summary": "{accepted} прийнято, {pending} очікує на розгляд", "status_summary": "{accepted} прийнято, {pending} очікує на розгляд",
+72 -5
View File
@@ -671,14 +671,24 @@
"contacts": "联系人", "contacts": "联系人",
"encryption": "加密", "encryption": "加密",
"sidebar_apps": "侧边栏应用", "sidebar_apps": "侧边栏应用",
"notifications": "通知" "notifications": "通知",
"layout": "布局",
"reading": "阅读",
"composing": "撰写",
"content_senders": "内容和发件人",
"about_data": "关于和数据",
"debug": "调试"
}, },
"tab_groups": { "tab_groups": {
"general": "通用", "general": "通用",
"account": "账户与身份", "account": "账户与身份",
"organization": "邮件组织", "organization": "邮件组织",
"apps": "应用", "apps": "应用",
"system": "系统" "system": "系统",
"appearance": "外观",
"mail": "邮件",
"privacy": "隐私和安全",
"advanced": "高级"
}, },
"appearance": { "appearance": {
"title": "外观", "title": "外观",
@@ -1737,6 +1747,8 @@
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入", "empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
"empty_search": "没有符合您搜索条件的联系人", "empty_search": "没有符合您搜索条件的联系人",
"empty_search_hint": "尝试不同的搜索词", "empty_search_hint": "尝试不同的搜索词",
"empty_filtered": "没有符合您筛选条件的联系人",
"empty_filtered_hint": "请调整或清除筛选条件",
"clear_search": "清除搜索", "clear_search": "清除搜索",
"import_vcard": "导入 vCard", "import_vcard": "导入 vCard",
"delete_confirm_title": "删除联系人", "delete_confirm_title": "删除联系人",
@@ -1814,7 +1826,22 @@
"calendar": "日历", "calendar": "日历",
"calendar_uri": "日历 URL", "calendar_uri": "日历 URL",
"scheduling_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": { "activity": {
"recent_emails": "Recent Emails", "recent_emails": "Recent Emails",
@@ -1823,7 +1850,9 @@
"no_events": "No upcoming events", "no_events": "No upcoming events",
"no_subject": "(No subject)", "no_subject": "(No subject)",
"no_title": "(No title)", "no_title": "(No title)",
"load_failed": "Failed to load" "load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
}, },
"form": { "form": {
"create_title": "新联系人", "create_title": "新联系人",
@@ -1919,7 +1948,13 @@
"email_invalid": "请输入有效的邮箱地址", "email_invalid": "请输入有效的邮箱地址",
"email_error_inline": "邮箱地址格式无效", "email_error_inline": "邮箱地址格式无效",
"save_failed": "保存联系人失败", "save_failed": "保存联系人失败",
"delete": "删除" "delete": "删除",
"upload_photo": "上传照片",
"remove_photo": "移除照片",
"photo_hint": "JPG 或 PNG, 最大 10MB。将会调整大小。",
"photo_too_large": "图像过大 (最大 10MB)",
"photo_invalid": "无效的图像文件",
"change_photo": "更改"
}, },
"groups": { "groups": {
"create": "新建群组", "create": "新建群组",
@@ -1979,6 +2014,37 @@
"error_create": "创建联系人失败", "error_create": "创建联系人失败",
"error_update": "无法更新联系人", "error_update": "无法更新联系人",
"error_delete": "删除联系人失败" "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": { "calendar": {
@@ -2054,6 +2120,7 @@
"tentative": "暂定", "tentative": "暂定",
"needs_action": "待处理", "needs_action": "待处理",
"remove": "移除", "remove": "移除",
"edit": "编辑",
"email_placeholder": "添加邮箱地址或搜索联系人", "email_placeholder": "添加邮箱地址或搜索联系人",
"send_invitations": "向参与者发送邀请", "send_invitations": "向参与者发送邀请",
"status_summary": "{accepted} 已接受,{pending} 待处理", "status_summary": "{accepted} 已接受,{pending} 待处理",
+933 -1283
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.14", "version": "1.5.0",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@@ -32,62 +32,62 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.24",
"@tiptap/extension-color": "^3.20.4", "@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-image": "^3.20.4", "@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.20.4", "@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-text-align": "^3.20.4", "@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.20.4", "@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.20.4", "@tiptap/extension-underline": "^3.22.4",
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.20.4", "@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.20.4", "@tiptap/starter-kit": "^3.22.4",
"asn1js": "^3.0.7", "asn1js": "^3.0.10",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.575.0", "lucide-react": "^1.8.0",
"next": "^16.1.5", "next": "^16.2.4",
"next-intl": "^4.5.8", "next-intl": "^4.9.1",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"pkijs": "^3.3.3", "pkijs": "^3.4.0",
"postal-mime": "^2.7.4", "postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6", "pvtsutils": "^1.3.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.1", "react": "^19.2.5",
"react-dom": "^19.2.1", "react-dom": "^19.2.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"webcrypto-liner": "^1.4.3", "webcrypto-liner": "^1.4.3",
"zustand": "^5.0.9" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4.2.4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@types/node": "^25.2.3", "@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.49.0", "@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^6.0.1",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.1.5",
"eslint": "^9.39.2", "eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5", "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", "fake-indexeddb": "^6.2.5",
"globals": "^17.0.0", "globals": "^17.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.2.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.16" "vitest": "^4.1.5"
}, },
"overrides": { "overrides": {
"elliptic": { "elliptic": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+110 -7
View File
@@ -53,6 +53,7 @@ ENV_FILE="${SCRIPT_DIR}/.env.local"
# Config values (defaults) # Config values (defaults)
CFG_APP_NAME="Bulwark Webmail" CFG_APP_NAME="Bulwark Webmail"
CFG_JMAP_SERVER_URL="" CFG_JMAP_SERVER_URL=""
CFG_ALLOW_CUSTOM_JMAP_ENDPOINT="false"
CFG_STALWART_FEATURES="true" CFG_STALWART_FEATURES="true"
CFG_OAUTH_ENABLED="false" CFG_OAUTH_ENABLED="false"
CFG_OAUTH_ONLY="false" CFG_OAUTH_ONLY="false"
@@ -64,16 +65,23 @@ CFG_SETTINGS_SYNC_ENABLED="false"
CFG_SETTINGS_DATA_DIR="./data/settings" CFG_SETTINGS_DATA_DIR="./data/settings"
CFG_LOG_FORMAT="text" CFG_LOG_FORMAT="text"
CFG_LOG_LEVEL="info" CFG_LOG_LEVEL="info"
CFG_APP_SHORT_NAME=""
CFG_APP_DESCRIPTION=""
CFG_LOGIN_COMPANY_NAME="" CFG_LOGIN_COMPANY_NAME=""
CFG_LOGIN_LOGO_LIGHT_URL="" CFG_LOGIN_LOGO_LIGHT_URL=""
CFG_LOGIN_LOGO_DARK_URL="" CFG_LOGIN_LOGO_DARK_URL=""
CFG_FAVICON_URL="" CFG_FAVICON_URL=""
CFG_PWA_ICON_URL=""
CFG_PWA_THEME_COLOR=""
CFG_PWA_BACKGROUND_COLOR=""
CFG_APP_LOGO_LIGHT_URL="" CFG_APP_LOGO_LIGHT_URL=""
CFG_APP_LOGO_DARK_URL="" CFG_APP_LOGO_DARK_URL=""
CFG_LOGIN_IMPRINT_URL="" CFG_LOGIN_IMPRINT_URL=""
CFG_LOGIN_PRIVACY_POLICY_URL="" CFG_LOGIN_PRIVACY_POLICY_URL=""
CFG_LOGIN_WEBSITE_URL="" CFG_LOGIN_WEBSITE_URL=""
CFG_EXTENSION_DIRECTORY_URL=""
CFG_DEPLOY_METHOD="" CFG_DEPLOY_METHOD=""
CFG_HOSTNAME="0.0.0.0"
CFG_PORT="3000" CFG_PORT="3000"
CURRENT_STEP=0 CURRENT_STEP=0
@@ -593,9 +601,19 @@ screen_server_config() {
prompt_yesno "Enable Stalwart-specific features?" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES" prompt_yesno "Enable Stalwart-specific features?" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES"
echo "" echo ""
echo -e " ${BOLD}Network${RESET}" echo -e " ${DIM}Adds a \"JMAP Server\" field to the login form so users can${RESET}"
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${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 ""
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" prompt_value "Port" "$CFG_PORT" "CFG_PORT"
draw_footer draw_footer
@@ -770,32 +788,65 @@ screen_login_customization() {
echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field." echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field."
echo "" 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}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}" echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}"
prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL" prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL"
echo "" 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}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}"
echo -e " ${DIM}Leave blank for no sidebar logo.${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" prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL"
echo "" echo ""
prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL" prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL"
echo "" 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}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}" 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" prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
echo "" echo ""
prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL" prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
echo "" 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" prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL"
echo "" echo ""
prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL" prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL"
echo "" echo ""
prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL" 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 "" echo ""
note "No branding configured. The app will use defaults." note "No branding configured. The app will use defaults."
fi fi
@@ -866,7 +917,9 @@ screen_summary() {
echo -e " ${CYAN}${BOLD}SERVER${RESET}" echo -e " ${CYAN}${BOLD}SERVER${RESET}"
echo -e " App Name .............. ${BOLD}${CFG_APP_NAME}${RESET}" echo -e " App Name .............. ${BOLD}${CFG_APP_NAME}${RESET}"
echo -e " JMAP Server URL ....... ${BOLD}${CFG_JMAP_SERVER_URL}${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 " Stalwart Features ..... ${BOLD}${CFG_STALWART_FEATURES}${RESET}"
echo -e " Hostname .............. ${BOLD}${CFG_HOSTNAME}${RESET}"
echo -e " Port .................. ${BOLD}${CFG_PORT}${RESET}" echo -e " Port .................. ${BOLD}${CFG_PORT}${RESET}"
echo "" echo ""
@@ -905,9 +958,19 @@ screen_summary() {
# Login page # Login page
echo -e " ${CYAN}${BOLD}BRANDING${RESET}" 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" ]] && \ [[ -n "$CFG_FAVICON_URL" ]] && \
echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}" 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" ]] && \ [[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \
echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}" echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}"
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \ [[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \
@@ -929,6 +992,13 @@ screen_summary() {
fi fi
echo "" 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 # Deployment
echo -e " ${CYAN}${BOLD}DEPLOYMENT${RESET}" echo -e " ${CYAN}${BOLD}DEPLOYMENT${RESET}"
case "$CFG_DEPLOY_METHOD" in case "$CFG_DEPLOY_METHOD" in
@@ -978,10 +1048,15 @@ write_env_file() {
# -- JMAP Server (required) --------------------------------------------------- # -- JMAP Server (required) ---------------------------------------------------
APP_NAME=${CFG_APP_NAME} APP_NAME=${CFG_APP_NAME}
JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL} JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL}
ALLOW_CUSTOM_JMAP_ENDPOINT=${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}
# -- Stalwart Mail Server Integration ----------------------------------------- # -- Stalwart Mail Server Integration -----------------------------------------
STALWART_FEATURES=${CFG_STALWART_FEATURES} STALWART_FEATURES=${CFG_STALWART_FEATURES}
# -- Server Listen Address ----------------------------------------------------
HOSTNAME=${CFG_HOSTNAME}
PORT=${CFG_PORT}
# -- OAuth / OpenID Connect --------------------------------------------------- # -- OAuth / OpenID Connect ---------------------------------------------------
OAUTH_ENABLED=${CFG_OAUTH_ENABLED} OAUTH_ENABLED=${CFG_OAUTH_ENABLED}
ENVEOF ENVEOF
@@ -1025,7 +1100,12 @@ LOG_LEVEL=${CFG_LOG_LEVEL}
# -- Branding ------------------------------------------------------------------ # -- Branding ------------------------------------------------------------------
ENVEOF 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_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_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_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" [[ -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_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" [[ -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}" echo -e " ${OK} Written ${BOLD}.env.local${RESET}"
} }
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
-857
View File
@@ -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
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
Binary file not shown.
-844
View File
@@ -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
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
-843
View File
@@ -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]
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More