Compare commits

..
59 Commits
Author SHA1 Message Date
Linus Rath 27451807db chore: update version to 1.5.2 2026-04-27 22:45:23 +02:00
Linus Rath 55099bdcbb fix: disable Stalwart admin check when custom JMAP endpoints allowed 2026-04-27 22:41:51 +02:00
Linus Rath 71eb720065 chore: update version to 1.5.2 2026-04-27 22:30:32 +02:00
Linus Rath 8abb0c8717 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 22:24:59 +02:00
Linus Rath 3043639d2d fix: validate URLs before outbound fetch 2026-04-27 22:23:39 +02:00
Linus Rath e9b3eacbb7 fix: prevent drag creation on touch events in useTimeGridInteractions 2026-04-27 13:37:34 +02:00
Linus Rath f37e55e285 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-04-27 10:36:22 +02:00
c0af2dbdd1 i18n: Fix word 'ficheiro' and its variants.
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:17:32 +02:00
Luis Felipe MarzagaoandLinus Rath ae517732f7 i18n: Fix word 'contactos' and its variants. 2026-04-27 10:17:32 +02:00
Linus Rath 3e1de10213 feat: add JMAP sharing for calendars and address books 2026-04-26 20:10:04 +02:00
Roman VanicekandLinus Rath 511740bb6d Additional Czech translations for recent changes. 2026-04-26 15:48:43 +02:00
Roman VanicekandLinus Rath 4a24d2a11d feat: add Czech language support 2026-04-26 15:48:43 +02:00
Linus Rath aadf56c27b fix: emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import #224 #187 2026-04-26 02:47:19 +02:00
Linus Rath 9f8588eadc fix: hide preview line in compact density to match settings preview (#223) 2026-04-26 02:12:28 +02:00
Linus Rath d657aec391 feat: add support for right-side composer sidebar and update related types 2026-04-25 19:45:45 +02:00
Linus RathandCopilot e683c90404 fix: implement inline matcher for Next.js proxy and remove unnecessary config
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 19:25:15 +02:00
Linus Rath cfb4a23c9d fix: remove unnecessary runtime config for Node.js in proxy settings 2026-04-25 18:54:15 +02:00
Linus Rath fe1d4861bb feat: composer-sidebar slot + plugin-declared frame-src origins 2026-04-25 18:40:54 +02:00
Linus Rath 5aa9b1d5f9 fix: honor SESSION_SECRET_FILE in dashboard warning check #222 2026-04-25 17:21:17 +02:00
Linus Rath 0913dbd3e4 chore: update version to 1.5.1 2026-04-25 16:34:45 +02:00
Linus Rath 29197ea355 feat: replace folder prompt() calls with proper modal dialog 2026-04-25 16:30:18 +02:00
Linus Rath 4788e8a91a feat: add right-click context menu to mail folders sidebar 2026-04-25 16:26:05 +02:00
Linus Rath b80678b00f feat: add 'Today' button to desktop calendar toolbar 2026-04-25 03:11:16 +02:00
Linus Rath 4f7c9c332b feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs 2026-04-25 02:53:12 +02:00
Linus Rath da103ff06f feat: implement OAuth auto-setup functionality for Stalwart integration 2026-04-25 01:37:18 +02:00
Linus Rath 2111c77870 fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:13:17 +02:00
Linus Rath e5083ec1df fix: restore admin panel after Stalwart v0.16 REST API removal 2026-04-25 01:12:33 +02:00
Linus Rath df8d04e233 fix: restore broken viewer toolbar actions and improve mobile menu #220 2026-04-24 19:31:38 +02:00
Linus RathandGitHub 9a11a18a44 Update README.md 2026-04-24 17:02:00 +02:00
Christian Lepthien ce9f7af330 fix: improve corean flag 2026-04-24 14:56:58 +02:00
Linus Rath 081e8a0310 feat: add setting to show avatars in junk folder, disabled by default 2026-04-23 18:23:48 +02:00
Linus Rath 6c3529b368 fix: stop flicker on background folder refresh 2026-04-23 18:06:40 +02:00
Linus Rath 077a4f03a7 fix: preserve search/filter on batch move and archive 2026-04-23 18:01:21 +02:00
Linus Rath b04dfaf252 fix: preserve search/filter when moving emails via drag-drop 2026-04-23 16:37:17 +02:00
Linus Rath 9c7452e7fd chore: add admin dashboard data directory configuration to environment files 2026-04-23 11:22:14 +02:00
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
168 changed files with 12353 additions and 94564 deletions
+11 -3
View File
@@ -71,11 +71,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, the working directory is /app, so the default resolves to
# /app/data/settings - mount a persistent volume there:
# volumes:
# - bulwark-settings:/app/data/settings
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
# SETTINGS_DATA_DIR=./data/settings
# =============================================================================
# Admin Dashboard Data
# =============================================================================
# Directory for admin dashboard state: config overrides, admin password hash,
# installed plugins/themes, and audit logs (default: ./data/admin).
# For Docker, the default resolves to /app/data/admin - mount a persistent
# volume there (see docker-compose.yml).
# ADMIN_DATA_DIR=./data/admin
# =============================================================================
# Server Listen Address
# =============================================================================
+81
View File
@@ -1,5 +1,86 @@
# Changelog
## 1.5.2 (2026-04-27)
### Features
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
- **Calendar/Contacts**: JMAP sharing for calendars and address books
- **i18n**: Czech language support
### Fixes
- **Security**: Validate URLs before outbound fetch
- **Calendar**: Prevent drag creation on touch events in the time grid
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
- **Mail**: Hide preview line in compact density to match settings preview (#223)
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
## 1.5.1 (2026-04-25)
### Features
- **Stalwart**: OAuth auto-setup with dialog and validation for origin and issuer URLs
- **Mail**: Right-click context menu on the folders sidebar
- **Mail**: Replace folder `prompt()` calls with a proper modal dialog
- **Calendar**: Add 'Today' button to the desktop calendar toolbar
- **Junk**: Setting to show avatars in the Junk folder (off by default)
### Fixes
- **Admin**: Restore admin panel after Stalwart v0.16 REST API removal
- **Viewer**: Restore broken viewer toolbar actions and improve the mobile menu (#220)
- **Folders**: Stop flicker on background folder refresh
- **Email**: Preserve search/filter on batch move and archive
- **Email**: Preserve search/filter when moving emails via drag-drop
- **i18n**: Improve Korean flag
## 1.5.0 (2026-04-22)
### Breaking Changes
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
### Features
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
- **Admin**: Add API Keys management and IP allowlist for App Passwords
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
- **Contacts**: Add contact activity component showing recent emails and upcoming events
- **Contacts**: Add right-click context menu
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
- **Calendar**: Support resizing events from the top edge
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
- **Calendar**: Optimize layout of overlapping events
- **Calendar**: Add collapsible details to calendar invitation banner
- **Email**: Implement batch archiving and bulk moving of emails
- **Email**: Show full folder path in move/drop toast
- **Settings**: Reorganize settings into 6 groups with clearer tabs
- **Navigation**: Add account-addition button to the navigation rail
- **Mobile**: Streamline email viewer header layout
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
### Fixes
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
- **Mailbox**: Improve error message on mailbox creation failure
- **Auth**: Skip `checkAuth` on route change when already authenticated
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
- **UI**: Align hover styles and selection-toggle target with focused item
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
### Refactor
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
### Chore
- **i18n**: Add missing translation keys
- **Deps**: Bump dependencies to latest compatible versions
## 1.4.14 (2026-04-16)
Thank you for your donations:
+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
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
Built with Next.js and the JMAP protocol.
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
[![License: 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)
[![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.2-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)
</div>
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
## Screenshots
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
</picture>
<table>
<tr>
<td width="50%">
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
**Mail** - Three-pane layout with sidebar, email list, and viewer
</td>
<td width="50%">
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
**Calendar** - Month, week, day, and agenda views with event management
</td>
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
**Contacts** - Contact management with groups and vCard support
</td>
<td width="50%">
<img src="screenshots/files.png" width="100%" alt="File browser">
**Files** - Cloud file browser with upload, preview, and folder navigation
</td>
<td><sub><b>Calendar</b> month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
<td><sub><b>Contacts</b> multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
</tr>
<tr>
<td><img src="screenshots/theme.png" alt="Themes" /></td>
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
</tr>
<tr>
<td><sub><b>Themes</b> bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
<td><sub><b>Plugins</b> extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
</tr>
<tr>
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
<td><img src="screenshots/settings.png" alt="Settings" /></td>
</tr>
<tr>
<td><sub><b>Light mode</b> full theme support with intelligent color transformation for HTML emails.</sub></td>
<td><sub><b>Settings</b> appearance, identities, filters, templates, security, and more.</sub></td>
</tr>
</table>
<details>
<summary>More screenshots</summary>
<table>
<tr>
<td width="50%">
## Overview
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
**Light mode** - Full theme support with intelligent color transformation
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
</td>
<td width="50%">
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
<img src="screenshots/settings.png" width="100%" alt="Settings">
**Settings** - Appearance, identities, filters, templates, and more
</td>
</tr>
<tr>
<td width="50%">
<img src="screenshots/login.png" width="100%" alt="Login page">
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
</td>
<td width="50%">
</td>
</tr>
</table>
</details>
## Features
### Mail
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
- **Unified mailbox** - view emails across all accounts in a single list
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
- **Archive modes** - archive directly or organize archived mail by year or month
- **Print** emails directly from the viewer
- **Answered/forwarded status icons** in email list and thread views
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
- **Star/unstar** with configurable mark-as-read delay
- **Virtual scrolling** for large mailboxes
- **Quick reply** from the viewer
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
- **Sender avatars** - favicon-based with negative caching for performance
- **Recipient popover** for quick contact interaction
- **Plain text composer mode** and auto-select reply identity
- **Reply-to addresses** support in the composer
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
- **message/rfc822 unwrapping** for embedded messages
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
- **Tag counts** - unread and total counts displayed in sidebar
- **Browser history sync** - back/forward navigation mirrors mail view state
### Calendar
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
- **Event hover preview** popover with configurable details
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
- **iCalendar import** with preview, bulk create, and UID deduplication
- **iCal / webcal subscriptions** with editing and batch import
- **Birthday calendar** - auto-generated from contacts
- **Virtual locations** - video conference URLs as first-class event fields
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
- **Shared calendars** with visual grouping in the sidebar
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
- **Week numbers** in mini-calendar sidebar
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
- **Real-time sync** via JMAP push
### Contacts
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
- **Collapsible sidebar** with address book grouping and bulk operations
- **Contact groups** with group expansion and member management
- **vCard import/export** (RFC 6350) with duplicate detection
- **Trusted senders** stored in a dedicated JMAP address book
- **Autocomplete** in composer (To/Cc/Bcc)
- **Bulk operations** - multi-select, delete, group add, export
### Filters & Automation
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
- **External rule preservation** - rules authored in other clients are displayed and preserved
- **Raw Sieve editor** with syntax validation
- **Vacation responder** with date range scheduling and sidebar indicator
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
### Files
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
- **Dynamic upload limits** - respects the server-configured maximum upload size
- **Folder navigation** with breadcrumb path and tree sidebar
- **Grid and list views** with sorting by name, size, or date
- **Clipboard operations** - cut, copy, paste, duplicate files
- **File preview** for images, text, audio, video, and more
- **Favorites and recent files** for quick access
- **Bulk operations** - multi-select, delete, move, download
### Security & Privacy
- **External content blocked** by default - trusted senders list for auto-load
- **HTML sanitization** via DOMPurify with XSS prevention
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
- **SPF/DKIM/DMARC** status indicators
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
- **TOTP two-factor authentication**
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
- **Newsletter unsubscribe** (RFC 2369)
### Interface
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
- **Dark and light themes** with intelligent email color transformation
- **Always-light email rendering** option for problematic HTML messages in dark theme
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
- **Keyboard shortcuts** - full navigation without a mouse
- **Drag-and-drop** email organization between mailboxes and tag assignment
- **Interactive guided tour** - onboarding walkthrough for new users
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
- **Settings sync** - preferences synchronized with the server (encrypted)
- **Storage quota** display
- **Version badge** in settings
- **Focused mode** with proper viewport bounds
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
### Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
### Identity Management
- **Multiple sender identities** with per-identity signatures
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
- **Identity badges** in viewer and email list
### Multi-Account
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
- **Account switcher** with connection status, default account selection, and per-account logout
- **Per-account settings** - encrypted settings storage with server-side sync
- **Shared folders** across accounts
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
### Admin & Extensibility
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
- **Bundled plugins** - Jitsi Meet calendar integration
### Operations
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
- **Automatic update check** - server logs when a newer release is available
- **Logging categories** with `text` or `json` formats for log aggregation
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
Full feature list: **[FEATURES.md](FEATURES.md)**.
---
## Quick Start
### Docker (recommended)
### Docker
```bash
docker run -d -p 3000:3000 \
@@ -255,7 +82,7 @@ Or with Docker Compose:
```bash
cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL
# Edit .env.local set JMAP_SERVER_URL
docker compose up -d
```
@@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
cd webmail
npm install
cp .env.example .env.local
# Edit .env.local - set JMAP_SERVER_URL
# Edit .env.local set JMAP_SERVER_URL
npm run build && npm start
```
### Development
```bash
npm run dev # Start dev server (mock JMAP server included)
npm run typecheck # Type checking
npm run lint # Linting
npm run dev # Dev server with a mock JMAP server
npm run typecheck
npm run lint
```
## Configuration
Edit `.env.local`:
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env
# Required
@@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
APP_NAME=My Webmail
```
All variables are **runtime** - Docker deployments can be configured without rebuilding.
<details>
<summary>Server Listen Address</summary>
<summary>Server listen address</summary>
```env
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
PORT=3000 # Default listen port
PORT=3000
```
</details>
<details>
<summary>OAuth2/OIDC (SSO)</summary>
<summary>OAuth2 / OIDC</summary>
```env
OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
OAUTH_ISSUER_URL= # optional, for external IdPs
```
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
@@ -318,29 +143,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
</details>
<details>
<summary>Remember Me & Settings Sync</summary>
<summary>Session & settings sync</summary>
```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
SESSION_SECRET= # openssl rand -base64 32
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
SETTINGS_SYNC_ENABLED=true
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
```
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
</details>
<details>
<summary>Custom JMAP Endpoint</summary>
<summary>Custom JMAP endpoint</summary>
```env
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
ALLOW_CUSTOM_JMAP_ENDPOINT=true
```
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
</details>
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
```env
APP_NAME=My Webmail
APP_SHORT_NAME=Webmail # Home-screen label on mobile
APP_DESCRIPTION=Your personal mail # Shown during PWA install
APP_SHORT_NAME=Webmail
APP_DESCRIPTION=Your personal mail
FAVICON_URL=/branding/favicon.svg
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
PWA_THEME_COLOR=#3b82f6
PWA_BACKGROUND_COLOR=#ffffff
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
APP_LOGO_DARK_URL=/branding/logo-dark.svg
@@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
</details>
<details>
<summary>Extension Directory</summary>
<summary>Extension directory</summary>
```env
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
@@ -382,13 +206,13 @@ Enables the admin marketplace for browsing and installing plugins and themes.
</details>
<details>
<summary>Stalwart Integration & Logging</summary>
<summary>Stalwart integration & logging</summary>
```env
STALWART_FEATURES=true # Password change, sieve filters, etc.
STALWART_FEATURES=true # password change, Sieve filters, etc.
LOG_FORMAT=text # "text" or "json"
LOG_LEVEL=info # "error", "warn", "info", "debug"
LOG_LEVEL=info # error | warn | info | debug
```
</details>
@@ -423,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
## Why Stalwart?
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[GNU AGPL v3](LICENSE)
This repository also preserves the original MIT attribution notice for the
fork lineage in [NOTICE](NOTICE).
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
## Acknowledgments
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
+1 -1
View File
@@ -1 +1 @@
1.4.14
1.5.2
+87 -5
View File
@@ -47,7 +47,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
import { useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug";
@@ -72,7 +76,8 @@ export default function CalendarPage() {
calendars, events, selectedDate, viewMode, selectedCalendarIds,
isLoading, isLoadingEvents, supportsCalendar, error,
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
removeCalendar, clearCalendarEvents,
refreshAllSubscriptions, icalSubscriptions,
} = useCalendarStore();
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
@@ -91,6 +96,11 @@ export default function CalendarPage() {
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
const tMgmt = useTranslations("calendar.management");
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -126,8 +136,15 @@ export default function CalendarPage() {
}
}, [events, detailEvent]);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
@@ -1095,6 +1112,42 @@ export default function CalendarPage() {
}
updateCalendar(client, calendarId, { color });
} : undefined}
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
onCreateEvent={(cal: Calendar) => {
setDefaultCalendarIdForCreate(cal.id);
openCreateModal();
}}
onClearCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("clear_events"),
message: tMgmt("confirm_clear", { name: cal.name }),
variant: "destructive",
confirmText: tMgmt("clear_events"),
});
if (!ok) return;
try {
const count = await clearCalendarEvents(client, cal.id);
toast.success(tMgmt("events_cleared", { count }));
} catch {
toast.error(tMgmt("error_clear"));
}
} : undefined}
onDeleteCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("delete"),
message: tMgmt("confirm_delete", { name: cal.name }),
variant: "destructive",
confirmText: tMgmt("delete"),
});
if (!ok) return;
try {
await removeCalendar(client, cal.id);
toast.success(tMgmt("calendar_deleted"));
} catch {
toast.error(tMgmt("error_delete"));
}
} : undefined}
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
@@ -1158,11 +1211,12 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
isMobile={false}
@@ -1254,11 +1308,12 @@ export default function CalendarPage() {
calendars={calendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
currentUserEmails={currentUserEmails}
isMobile={true}
/>
@@ -1298,6 +1353,33 @@ export default function CalendarPage() {
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
<ConfirmDialog {...confirmDialogProps} />
{showCreateCalendar && client && (
<CreateCalendarModal
client={client}
onClose={() => setShowCreateCalendar(false)}
/>
)}
{sharingCalendarId && client && (() => {
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingCalendarId(null)}
/>
);
})()}
</div>
);
}
+85 -22
View File
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
type View =
| "list"
@@ -75,6 +76,8 @@ export default function ContactsPage() {
bulkAddToGroup,
moveContactToAddressBook,
renameAddressBook,
removeAddressBook,
shareAddressBook,
renameKeyword,
importContacts,
} = useContactStore();
@@ -83,6 +86,8 @@ export default function ContactsPage() {
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
const [showImportDialog, setShowImportDialog] = useState(false);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
@@ -98,13 +103,20 @@ export default function ContactsPage() {
// Panel resize state - contact list
const [listWidth, setListWidth] = useState(() => {
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
});
const [isListResizing, setIsListResizing] = useState(false);
const listDragStartWidth = useRef(320);
const listDragStartWidth = useRef(384);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
@@ -172,21 +184,6 @@ export default function ContactsPage() {
return getGroupMembers(activeCategory.groupId);
}, [activeCategory, individuals, getGroupMembers]);
// Label for the current category
const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all");
if (activeCategory === "uncategorized") return t("no_category");
if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all");
}
if ("keyword" in activeCategory) {
return activeCategory.keyword;
}
const group = contacts.find(c => c.id === activeCategory.groupId);
return group ? getContactDisplayName(group) : t("tabs.all");
}, [activeCategory, contacts, addressBooks, t]);
const handleSelectCategory = useCallback((category: ContactCategory) => {
setActiveCategory(category);
clearSelection();
@@ -306,6 +303,24 @@ export default function ContactsPage() {
setView("bulk-add-to-group");
}, [clearSelection, toggleContactSelection, groups.length]);
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
const { id: _id, created: _created, updated: _updated, ...rest } = source;
void _id; void _created; void _updated;
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
if (supportsSync && client) {
await createContact(client, data);
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
};
addLocalContact(localContact);
toast.success(t("toast.created"));
}
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) {
await createContact(client, data);
@@ -319,6 +334,7 @@ export default function ContactsPage() {
addLocalContact(localContact);
toast.success(t("toast.created"));
}
setDefaultBookIdForCreate(undefined);
setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]);
@@ -336,6 +352,7 @@ export default function ContactsPage() {
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
setDefaultBookIdForCreate(undefined);
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") {
@@ -507,7 +524,7 @@ export default function ContactsPage() {
const renderRightPanel = () => {
switch (view) {
case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit":
if (!selectedContact) return null;
@@ -607,6 +624,16 @@ export default function ContactsPage() {
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
onAddToGroup={
selectedContact
? () => handleAddContactToGroup(selectedContact.id)
: undefined
}
onDuplicate={
selectedContact
? () => void handleDuplicateContact(selectedContact)
: undefined
}
isMobile={isMobile}
/>
);
@@ -670,6 +697,26 @@ export default function ContactsPage() {
onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
onCreateContactInBook={(book) => {
setDefaultBookIdForCreate(book.id);
handleCreateNew();
}}
onDeleteAddressBook={client ? async (book) => {
const ok = await confirmDialog({
title: t("address_books.delete"),
message: t("address_books.confirm_delete", { name: book.name }),
variant: "destructive",
confirmText: t("address_books.delete"),
});
if (!ok) return;
try {
await removeAddressBook(client, book);
toast.success(t("address_books.deleted"));
} catch {
toast.error(t("address_books.delete_failed"));
}
} : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/>
</div>
@@ -702,7 +749,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
categoryLabel={categoryLabel}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
@@ -726,7 +772,7 @@ export default function ContactsPage() {
setIsListResizing(false);
localStorage.setItem("contacts-list-width", String(listWidth));
}}
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
/>
)}
</>
@@ -819,6 +865,23 @@ export default function ContactsPage() {
</div>
</div>
)}
{sharingAddressBookId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingAddressBookId(null)}
/>
);
})()}
</div>
);
}
+8 -1
View File
@@ -105,8 +105,15 @@ export default function FilesPage() {
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
+259 -32
View File
@@ -8,12 +8,13 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
@@ -23,6 +24,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { usePromptDialog } from "@/hooks/use-prompt-dialog";
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
@@ -35,6 +37,7 @@ import {
ComposerErrorFallback,
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
@@ -67,6 +70,7 @@ export default function Home() {
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
@@ -162,6 +166,11 @@ export default function Home() {
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
emptyMailbox,
markMailboxAsRead,
createMailbox,
renameMailbox,
deleteMailbox,
} = useEmailStore();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
@@ -455,8 +464,15 @@ export default function Home() {
document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
@@ -493,7 +509,10 @@ export default function Home() {
// Load mailboxes and emails when authenticated (only if not already loaded)
useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) {
const loadData = async () => {
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([
@@ -505,6 +524,15 @@ export default function Home() {
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
// On first login the server may still be provisioning mailboxes.
// Retry a few times with back-off before giving up.
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
// Fetch emails for the selected mailbox
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
@@ -538,6 +566,12 @@ export default function Home() {
}
};
loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
};
}
// Cleanup push notifications on unmount
@@ -813,8 +847,11 @@ export default function Home() {
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
if (!client || !emailToArchive) return;
// Find archive mailbox
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
// Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations.
const currentMailboxes = useEmailStore.getState().mailboxes;
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
if (!archiveMailbox) return;
const { archiveMode } = useSettingsStore.getState();
@@ -823,14 +860,12 @@ export default function Home() {
if (archiveMode === 'single') {
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
} else {
// Determine year/month from the email's received date
const emailDate = new Date(emailToArchive.receivedAt);
const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Find or create year subfolder under archive
let yearMailbox = mailboxes.find(
let yearMailbox = currentMailboxes.find(
m => m.name === year && m.parentId === archiveId
);
if (!yearMailbox) {
@@ -841,9 +876,9 @@ export default function Home() {
if (archiveMode === 'year') {
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
} else {
// archiveMode === 'month' - find or create month subfolder under year
const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = mailboxes.find(
const afterYear = useEmailStore.getState().mailboxes;
let monthMailbox = afterYear.find(
m => m.name === month && m.parentId === yearId
);
if (!monthMailbox) {
@@ -1071,6 +1106,197 @@ export default function Home() {
}
};
const tCtxMenu = t;
const handleMarkFolderRead = async (mailboxId: string) => {
if (!client) return;
try {
const count = await markMailboxAsRead(client, mailboxId);
await fetchMailboxes(client);
if (selectedMailbox === mailboxId) await fetchEmails(client, mailboxId);
if (count > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkFolderTreeRead = async (mailboxId: string) => {
if (!client) return;
const collectIds = (rootId: string): string[] => {
const ids: string[] = [rootId];
const stack = [rootId];
while (stack.length > 0) {
const current = stack.pop()!;
for (const mb of mailboxes) {
if (mb.parentId === current) {
ids.push(mb.id);
stack.push(mb.id);
}
}
}
return ids;
};
try {
const ids = collectIds(mailboxId);
let total = 0;
for (const id of ids) {
total += await markMailboxAsRead(client, id);
}
await fetchMailboxes(client);
if (selectedMailbox && ids.includes(selectedMailbox)) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleMarkAllFoldersRead = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.mark_all_confirm_title'),
message: tCtxMenu('mailbox_context_menu.mark_all_confirm_message'),
confirmText: tCtxMenu('mailbox_context_menu.mark_all_folders_read'),
variant: "default",
});
if (!confirmed) return;
try {
const total = await client.markAllAsRead();
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
if (total > 0) {
toast.success(tCtxMenu('mailbox_context_menu.toast_marked_read_count', { count: total }));
} else {
toast.success(tCtxMenu('mailbox_context_menu.toast_already_read'));
}
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_mark_read'));
}
};
const handleEmptyFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('email_list.empty_folder.confirm_title'),
message: tCtxMenu('email_list.empty_folder.confirm_message'),
confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
variant: "destructive",
});
if (!confirmed) return;
try {
await emptyMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_emptied'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_empty'));
}
};
const handleCreateSubfolderFromContextMenu = async (parentId: string) => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_subfolder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_subfolder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name, parentId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleCreateFolderFromContextMenu = async () => {
if (!client) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.new_folder'),
message: tCtxMenu('mailbox_context_menu.prompt_new_folder'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
confirmText: tCtxMenu('mailbox_context_menu.create'),
});
if (!name) return;
try {
await createMailbox(client, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
}
};
const handleRenameFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const name = await promptDialog({
title: tCtxMenu('mailbox_context_menu.rename'),
message: tCtxMenu('mailbox_context_menu.prompt_rename'),
placeholder: tCtxMenu('mailbox_context_menu.placeholder_folder_name'),
defaultValue: mailbox.name,
confirmText: tCtxMenu('mailbox_context_menu.rename_confirm'),
});
if (!name || name === mailbox.name) return;
try {
await renameMailbox(client, mailboxId, name);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_renamed'));
} catch {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_rename'));
}
};
const handleDeleteFolderFromContextMenu = async (mailboxId: string) => {
if (!client) return;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
const confirmed = await confirmDialog({
title: tCtxMenu('mailbox_context_menu.delete_confirm_title'),
message: tCtxMenu('mailbox_context_menu.delete_confirm_message', { name: mailbox.name }),
confirmText: tCtxMenu('mailbox_context_menu.delete_folder'),
variant: "destructive",
});
if (!confirmed) return;
try {
await deleteMailbox(client, mailboxId);
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted'));
} catch (err: unknown) {
const jmapType = (err as Error & { jmapType?: string })?.jmapType;
if (jmapType === 'mailboxHasChild') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children'));
} else if (jmapType === 'mailboxHasEmail') {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_email'));
} else {
toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete'));
}
}
};
const handleRefreshMailboxes = async () => {
if (!client) return;
try {
await fetchMailboxes(client);
if (selectedMailbox) await fetchEmails(client, selectedMailbox);
} catch {
// silent
}
};
const handleLogout = logout;
const handleSearch = async (query: string) => {
@@ -1269,15 +1495,11 @@ export default function Home() {
};
// Handle back navigation from viewer on mobile.
// Delegate to the browser history stack so this button is equivalent to
// the OS back button / mouse back button - popstate then restores the
// previous snapshot via handleNavRestore. The viewer is only reachable
// from a state that pushed history, so back() always lands on an app entry.
// Reset to list state directly. We can't just call window.history.back()
// because the nav hook pushes a new entry for every email the user opens,
// so history.back() would pop to the previous email rather than the list.
// The OS / hardware back button is still wired through popstate → handleNavRestore.
const handleMobileBack = () => {
if (typeof window !== 'undefined') {
window.history.back();
return;
}
if (conversationThread) {
setConversationThread(null);
setConversationEmails([]);
@@ -1432,6 +1654,15 @@ export default function Home() {
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
onMarkFolderRead={handleMarkFolderRead}
onMarkFolderTreeRead={handleMarkFolderTreeRead}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={handleEmptyFolderFromContextMenu}
onCreateSubfolder={handleCreateSubfolderFromContextMenu}
onCreateFolder={handleCreateFolderFromContextMenu}
onRenameFolder={handleRenameFolderFromContextMenu}
onDeleteFolder={handleDeleteFolderFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
@@ -1492,9 +1723,12 @@ export default function Home() {
} else {
selectAllEmails();
}
} else {
// Enter selection mode by selecting the first email
if (emails.length > 0) toggleEmailSelection(emails[0].id);
} else if (emails.length > 0) {
const currentId = selectedEmail?.id;
const target = currentId && emails.some((e) => e.id === currentId)
? currentId
: emails[0].id;
toggleEmailSelection(target);
}
}}
className={cn(
@@ -1898,14 +2132,6 @@ export default function Home() {
/>
) : (
<>
{/* Mobile Header for Viewer */}
{isMobile && activeView === "viewer" && (
<MobileViewerHeader
subject={selectedEmail?.subject}
onBack={handleMobileBack}
/>
)}
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
@@ -1913,12 +2139,12 @@ export default function Home() {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onDelete={() => handleDelete()}
onArchive={() => handleArchive()}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam}
onUndoSpam={handleUndoSpam}
onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
@@ -1988,6 +2214,7 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} />
<TotpReauthDialog />
</div>
</DragDropProvider>
+152 -82
View File
@@ -9,7 +9,6 @@ import {
LogOut,
Settings as SettingsIcon,
Palette,
Mail,
User,
Shield,
UserPen,
@@ -20,17 +19,27 @@ import {
FolderOpen,
Tags,
HardDrive,
Wrench,
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
LayoutGrid,
BookOpen,
PenLine,
EyeOff,
Languages,
Info,
Bug,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
import { ComposingSettings } from '@/components/settings/composing-settings';
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { AboutDataSettings } from '@/components/settings/about-data-settings';
import { DebugSettings } from '@/components/settings/debug-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
type Tab =
| 'account'
| 'language'
| 'notifications'
| 'appearance'
| 'layout'
| 'reading'
| 'composing'
| 'identities'
| 'vacation'
| 'filters'
| 'templates'
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
| 'files'
| 'sidebar_apps'
| 'about_data'
| 'themes'
| 'plugins'
| 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef {
id: Tab;
@@ -74,28 +109,54 @@ interface TabDef {
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
notifications: Bell,
account: User,
security: Shield,
language: Languages,
notifications: Bell,
appearance: Palette,
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
identities: UserPen,
encryption: KeyRound,
vacation: PalmtreeIcon,
calendar: Calendar,
contacts: BookUser,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette,
plugins: Puzzle,
advanced: Wrench,
debug: Bug,
};
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
// Map legacy tab IDs to current ones; runs once on read of localStorage.
const LEGACY_TAB_MAP: Record<string, Tab> = {
email: 'reading',
advanced: 'about_data',
};
function readPersistedTab(): Tab {
try {
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) {
const migrated = LEGACY_TAB_MAP[saved];
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
} catch {
return 'appearance';
}
}
export default function SettingsPage() {
const router = useRouter();
@@ -107,13 +168,7 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(() => {
try {
const saved = localStorage.getItem('settings-active-tab');
if (saved) return saved as Tab;
} catch { /* ignore */ }
return 'appearance';
});
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
@@ -124,21 +179,28 @@ export default function SettingsPage() {
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Check auth on mount
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Listen for tab change events from child components
// Listen for tab change events from child components (with legacy migration)
useEffect(() => {
const handler = (e: Event) => {
const tab = (e as CustomEvent).detail as Tab;
if (tab) {
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
}
const raw = (e as CustomEvent).detail as string;
if (!raw) return;
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
};
window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler);
@@ -161,25 +223,41 @@ export default function SettingsPage() {
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
// General
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
// Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// Group tabs by category
@@ -191,6 +269,10 @@ export default function SettingsPage() {
}))
.filter((g) => g.items.length > 0);
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
@@ -199,39 +281,42 @@ export default function SettingsPage() {
}
};
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'notifications' && <NotificationSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'security' && <AccountSecuritySettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'encryption' && <SmimeSettings />}
{activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
{activeTab === 'themes' && <ThemesSettings />}
{activeTab === 'plugins' && <PluginsSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'templates' && <TemplateSettings />}
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
</>
);
// Mobile layout
if (!isDesktop) {
// Mobile: show content view
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile content header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -244,14 +329,12 @@ export default function SettingsPage() {
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()}
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -264,10 +347,8 @@ export default function SettingsPage() {
);
}
// Mobile: show tab list
return (
<div className="flex flex-col h-dvh bg-background">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
@@ -283,7 +364,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{groupedTabs.map((group, groupIndex) => (
@@ -319,7 +399,6 @@ export default function SettingsPage() {
))}
</div>
{/* Logout */}
<div className="border-t border-border px-5 py-3">
<button
onClick={logout}
@@ -331,7 +410,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Bottom Navigation */}
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -347,7 +425,6 @@ export default function SettingsPage() {
// Desktop layout
return (
<div className="flex h-dvh bg-background">
{/* Navigation Rail */}
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -366,7 +443,6 @@ export default function SettingsPage() {
)}
{!inlineApp && (
<>
{/* Settings Sidebar */}
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
@@ -374,7 +450,6 @@ export default function SettingsPage() {
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
{/* Header */}
<div className="p-4 border-b border-border">
<Button
variant="ghost"
@@ -387,7 +462,6 @@ export default function SettingsPage() {
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
@@ -406,14 +480,14 @@ export default function SettingsPage() {
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
activeTab === tab.id
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
{tab.experimental && (
@@ -430,7 +504,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Sidebar resize handle */}
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
@@ -441,10 +514,8 @@ export default function SettingsPage() {
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
{/* Settings Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
@@ -452,7 +523,6 @@ export default function SettingsPage() {
</div>
</div>
{/* Active Tab Content */}
<div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()}
</div>
+166 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw } from 'lucide-react';
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
@@ -69,6 +69,58 @@ export default function AdminAuthPage() {
}
}
const [setupRunning, setSetupRunning] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupOrigin, setSetupOrigin] = useState('');
const [setupIssuer, setSetupIssuer] = useState('');
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
function openSetupDialog() {
if (typeof window === 'undefined') return;
const origin = window.location.origin;
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
setSetupOrigin(origin);
setSetupIssuer(jmapUrl || origin);
setSetupOauthOnly(currentValue('oauthOnly') === true);
setSetupOpen(true);
}
async function handleAutoSetup() {
setSetupRunning(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/oauth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin: setupOrigin.trim().replace(/\/+$/, ''),
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
oauthOnly: setupOauthOnly,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
});
setEdits({});
setSetupOpen(false);
await fetchConfig();
} else {
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
}
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
} finally {
setSetupRunning(false);
}
}
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
const hasEdits = Object.keys(edits).length > 0;
if (loading) {
@@ -100,6 +152,119 @@ export default function AdminAuthPage() {
</div>
)}
{/* Auto-setup */}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
Requires your Stalwart account to have admin permissions.
</p>
</div>
<button
onClick={openSetupDialog}
disabled={setupRunning}
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</button>
</div>
</div>
{/* Auto-setup dialog */}
{setupOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="oauth-setup-title"
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
>
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
<div className="px-5 py-4 border-b border-border">
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
<p className="text-xs text-muted-foreground mt-1">
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
</p>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
Webmail origin
</label>
<input
id="setup-origin"
type="url"
value={setupOrigin}
onChange={(e) => setSetupOrigin(e.target.value)}
disabled={setupRunning}
placeholder="https://webmail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/&lt;locale&gt;/auth/callback</code>) on Stalwart.
</p>
{!setupOriginValid && setupOrigin.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<div>
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
Stalwart issuer URL
</label>
<input
id="setup-issuer"
type="url"
value={setupIssuer}
onChange={(e) => setSetupIssuer(e.target.value)}
disabled={setupRunning}
placeholder="https://mail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
</p>
{!setupIssuerValid && setupIssuer.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
<input
type="checkbox"
checked={setupOauthOnly}
onChange={(e) => setSetupOauthOnly(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input"
disabled={setupRunning}
/>
Also enable OAuth only (hide password login)
</label>
</div>
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
<button
onClick={() => setSetupOpen(false)}
disabled={setupRunning}
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleAutoSetup}
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Configure'}
</button>
</div>
</div>
</div>
)}
{/* OAuth */}
<Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
+57 -48
View File
@@ -65,6 +65,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
@@ -73,54 +74,59 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
useEffect(() => {
if (pathname !== '/admin/login') {
checkAuth();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
if (pathname === '/admin/login') return;
let cancelled = false;
function getJmapHeaders(): Record<string, string> {
return getActiveAccountSlotHeaders();
}
async function checkAuth() {
try {
const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
async function checkAuth() {
try {
const jmapHeaders = getJmapHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
// If neither password-based admin nor Stalwart admin, redirect away
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
// If neither password-based admin nor Stalwart admin, redirect away
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (loginRes.ok) {
if (data.authenticated) {
setAuthenticated(true);
return;
}
}
router.replace('/admin/login');
} catch {
router.replace('/admin/login');
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (cancelled) return;
if (loginRes.ok) {
setAuthenticated(true);
return;
}
const body = await loginRes.json().catch(() => ({}));
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
setAuthenticated(false);
return;
}
router.replace('/admin/login');
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
}
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router]);
async function handleLogout() {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
@@ -132,14 +138,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return <>{children}</>;
}
if (authenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */}
@@ -269,7 +267,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto p-6">
{children}
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div>
</main>
</div>
Binary file not shown.
+25 -1
View File
@@ -10,6 +10,10 @@ import {
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
import {
sanitizeFrameOrigins,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
}
// Plugins may declare iframe origins they need for embedded content.
// Anything that doesn't pass strict origin validation is silently
// dropped — the plugin still installs, but those origins are not
// added to the host CSP.
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
? (manifest.frameOrigins as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
)
: [];
if (droppedFrameOrigins.length > 0) {
warnings.push(
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
);
}
const plugin: ServerPlugin = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
enabled: true,
installedAt: now,
updatedAt: now,
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
};
await savePlugin(plugin, code);
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ success: true, plugin, warnings });
}
+253
View File
@@ -0,0 +1,253 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomBytes } from 'node:crypto';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { locales as ALL_LOCALES } from '@/i18n/routing';
const CLIENT_ID = 'bulwark-webmail';
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
const JMAP_TIMEOUT_MS = 10_000;
interface JmapMethodCall {
using: string[];
methodCalls: Array<[string, Record<string, unknown>, string]>;
}
interface JmapMethodResponse {
methodResponses?: Array<[string, Record<string, unknown>, string]>;
}
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function jmapCall(
serverUrl: string,
authHeader: string,
body: JmapMethodCall,
): Promise<JmapMethodResponse> {
const res = await fetchWithTimeout(`${serverUrl}/jmap/`, {
method: 'POST',
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
}
return res.json() as Promise<JmapMethodResponse>;
}
async function getStalwartAccountId(
serverUrl: string,
authHeader: string,
): Promise<string | null> {
const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, {
method: 'GET',
headers: { 'Authorization': authHeader },
});
if (!res.ok) return null;
const session = await res.json() as { primaryAccounts?: Record<string, string> };
return session.primaryAccounts?.['urn:stalwart:jmap']
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
?? Object.values(session.primaryAccounts ?? {})[0]
?? null;
}
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
const out: Record<string, true> = {};
for (const loc of localeList) {
out[`${origin}/${loc}/auth/callback`] = true;
}
return out;
}
interface SetupRequestBody {
origin?: string;
issuerUrl?: string;
locales?: string[];
oauthOnly?: boolean;
}
function isValidOriginUrl(value: string): boolean {
return /^https?:\/\/[^/]+$/.test(value);
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json(
{ error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
{ status: 400 },
);
}
const body = await request.json() as SetupRequestBody;
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
if (!isValidOriginUrl(origin)) {
return NextResponse.json(
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
{ status: 400 },
);
}
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
if (!isValidOriginUrl(issuerUrl)) {
return NextResponse.json(
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
{ status: 400 },
);
}
const localeList = Array.isArray(body.locales) && body.locales.length > 0
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
: Array.from(ALL_LOCALES);
if (localeList.length === 0) {
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
}
const oauthOnly = body.oauthOnly === true;
const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader);
if (!accountId) {
return NextResponse.json(
{ error: 'Could not resolve Stalwart account from JMAP session.' },
{ status: 502 },
);
}
const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [[
'x:OAuthClient/query',
{ accountId, filter: { clientId: CLIENT_ID } },
'0',
]],
});
const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/query — your Stalwart account likely lacks admin permissions.',
detail: queryEntry?.[1],
}, { status: 403 });
}
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
const secret = randomBytes(32).toString('base64url');
const redirectUris = buildRedirectUris(origin, localeList);
let setArgs: Record<string, unknown>;
let action: 'created' | 'updated';
if (existingIds.length > 0) {
const targetId = existingIds[0];
action = 'updated';
setArgs = {
accountId,
update: {
[targetId]: {
secret,
redirectUris,
description: CLIENT_DESCRIPTION,
},
},
};
} else {
action = 'created';
setArgs = {
accountId,
create: {
new: {
clientId: CLIENT_ID,
description: CLIENT_DESCRIPTION,
secret,
redirectUris,
contacts: { [creds.username]: true },
},
},
};
}
const setRes = await jmapCall(creds.serverUrl, creds.authHeader, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
methodCalls: [['x:OAuthClient/set', setArgs, '0']],
});
const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/set — admin permissions required.',
detail: setEntry?.[1],
}, { status: 403 });
}
const setBody = setEntry[1] as {
notCreated?: Record<string, unknown>;
notUpdated?: Record<string, unknown>;
};
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
{ status: 502 },
);
}
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
return NextResponse.json(
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
{ status: 502 },
);
}
await configManager.ensureLoaded();
const updates: Record<string, unknown> = {
oauthEnabled: true,
oauthClientId: CLIENT_ID,
oauthClientSecret: secret,
oauthIssuerUrl: issuerUrl,
};
if (oauthOnly) updates.oauthOnly = true;
await configManager.setAdminConfig(updates);
await auditLog('admin.oauth_setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
redirectUriCount: localeList.length,
oauthOnly,
}, ip);
logger.info('Admin OAuth setup', {
action,
clientId: CLIENT_ID,
origin,
issuer: issuerUrl,
locales: localeList.length,
});
return NextResponse.json({
ok: true,
action,
clientId: CLIENT_ID,
origin,
issuerUrl,
redirectUriCount: localeList.length,
});
} catch (error) {
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 },
);
}
}
+17 -1
View File
@@ -8,6 +8,10 @@ import {
deletePlugin as removePlugin,
type ServerPlugin,
} from '@/lib/admin/plugin-registry';
import {
sanitizeFrameOrigins,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
// Server-side extraction using the same validation logic
// ZIP parsing needs to happen on the server for admin-uploaded plugins
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
);
}
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const now = new Date().toISOString();
const plugin: ServerPlugin = {
id: manifest.id as string,
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins }
: {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ plugin });
} catch (error) {
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
// Enable/disable changes the set of plugins contributing frame origins.
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
invalidateFrameOriginsCache();
}
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
invalidateFrameOriginsCache();
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
+4 -1
View File
@@ -9,6 +9,7 @@ import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number {
export async function POST(request: NextRequest) {
try {
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
if (oauthEnabled && oauthOnly) {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
}
+3 -2
View File
@@ -5,6 +5,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -113,8 +114,8 @@ async function attemptAllStrategies(
): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = [];
+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.',
hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }],
htmlBody: [],
bodyValues: {
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
p2: { value: '<p>Hallo!</p><p>This is a sample email to help you get started with the <strong>Bulwark Webmail</strong> development environment.</p><p>Feel free to explore the UI - all data here is mock data.</p><p>Beste Grüße,<br>Sophie</p>' },
},
},
{
+3 -84
View File
@@ -1,89 +1,9 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return false;
}
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.endsWith('.localdomain')
) {
return false;
}
// Block URLs with credentials
if (url.username || url.password) {
return false;
}
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
let body: { url?: string };
try {
@@ -98,7 +18,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!(await isValidExternalUrl(url))) {
if (!(await isPublicHttpUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -111,7 +31,7 @@ export async function POST(request: NextRequest) {
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isValidExternalUrl(currentUrl))) {
if (!(await isPublicHttpUrl(currentUrl))) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
@@ -131,7 +51,6 @@ export async function POST(request: NextRequest) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
}
// Resolve relative redirects
currentUrl = new URL(location, currentUrl).toString();
continue;
}
+16 -3
View File
@@ -119,6 +119,7 @@ export function CalendarDayView({
created: t("notifications.event_created"),
error: t("notifications.event_error"),
},
isMobile,
});
const formatHour = (h: number): string => {
@@ -246,9 +247,11 @@ export function CalendarDayView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100;
@@ -270,11 +273,21 @@ export function CalendarDayView({
onContextMenu={onContextMenuEvent}
draggable
/>
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
+127 -105
View File
@@ -1,8 +1,8 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps {
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onShareCalendar?: (calendar: Calendar) => void;
onCreateEvent?: (calendar: Calendar) => void;
onClearCalendar?: (calendar: Calendar) => void;
onDeleteCalendar?: (calendar: Calendar) => void;
onCreateCalendar?: () => void;
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
selectedCalendarIds,
onToggleVisibility,
onColorChange,
onShareCalendar,
onCreateEvent,
onClearCalendar,
onDeleteCalendar,
onCreateCalendar,
onSubscribe,
onEditSubscription,
client,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
const tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => {
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
return Array.from(groups.values());
}, [calendars]);
useEffect(() => {
if (!colorPickerId && !contextMenuCalId) return;
const handleClick = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setColorPickerId(null);
}
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenuCalId(null);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setColorPickerId(null);
setContextMenuCalId(null);
}
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [colorPickerId, contextMenuCalId]);
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
const handleRefreshSubscription = async (subId: string) => {
if (!client) return;
setRefreshingSubId(subId);
setContextMenuCalId(null);
try {
await refreshICalSubscription(client, subId);
toast.success(tSub('refresh_success'));
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
const handleUnsubscribe = async (subId: string) => {
if (!client) return;
setContextMenuCalId(null);
try {
await removeICalSubscription(client, subId);
toast.success(tSub('deleted'));
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
const renderCalendarItem = (cal: Calendar) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
return (
<div key={cal.id} className="relative">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => {
setContextMenuCalId(null);
onEditSubscription?.(sub.id);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{tSub('edit')}
</button>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
ref={colorPickerRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
>
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
<CalendarColorPicker
value={color}
onChange={(c) => {
onColorChange(cal.id, c);
setColorPickerId(null);
}}
allowCustom
/>
</div>
)}
</div>
);
};
const renderCalendarMenu = () => {
const cal = contextMenu.data;
if (!cal) return null;
if (isSubscriptionCalendar(cal.id)) {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub || !client) return null;
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem
icon={Pencil}
label={tSub('edit')}
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
/>
<ContextMenuItem
icon={RefreshCw}
label={tSub('refresh')}
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={tSub('unsubscribe')}
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
destructive
/>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
</div>
)}
</ContextMenu>
);
}
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
const canChangeColor = !!onColorChange;
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
const color = cal.color || "#3b82f6";
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
{canCreate && (
<ContextMenuItem
icon={Plus}
label={tMgmt('new_event_in_calendar')}
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={tMgmt('share')}
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
)}
{canChangeColor && (
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
<div className="px-2 py-1.5 w-[200px]">
<CalendarColorPicker
value={color}
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
allowCustom
/>
</div>
</ContextMenuSubMenu>
)}
{showSeparator && <ContextMenuSeparator />}
{canClear && (
<ContextMenuItem
icon={Eraser}
label={tMgmt('clear_events')}
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
/>
)}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={tMgmt('delete')}
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
destructive
/>
)}
</ContextMenu>
);
};
return (
<div className="mt-4">
{enableCalendarTasks && (
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
)}
</button>
)}
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
</div>
</div>
))}
{renderCalendarMenu()}
</div>
);
}
+7 -4
View File
@@ -260,6 +260,9 @@ export function CalendarToolbar({
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
{t("views.today")}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
@@ -279,14 +282,14 @@ export function CalendarToolbar({
<div className="flex-1" />
{!isMobile && (
<div className="flex border border-border rounded-md overflow-hidden">
<div className="flex h-8 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
title={t(`views.${v}_hint`)}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors",
"inline-flex items-center px-3 text-xs font-medium transition-colors",
v === viewMode
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground"
@@ -300,7 +303,7 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
@@ -331,7 +334,7 @@ export function CalendarToolbar({
)}
{!isMobile && (
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" />
{t("events.create")}
</Button>
+16 -3
View File
@@ -183,6 +183,7 @@ export function CalendarWeekView({
created: t("notifications.event_created"),
error: t("notifications.event_error"),
},
isMobile,
});
const formatHour = (h: number): string => {
@@ -384,9 +385,11 @@ export function CalendarWeekView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const top = (startMinutes / 60) * HOUR_HEIGHT;
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const isResizing = resizeVisual?.eventId === ev.id;
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100;
@@ -408,11 +411,21 @@ export function CalendarWeekView({
onContextMenu={onContextMenuEvent}
draggable
/>
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
</div>
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
@@ -0,0 +1,151 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface CreateCalendarModalProps {
client: IJMAPClient;
onClose: () => void;
}
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
const t = useTranslations("calendar.management");
const tCommon = useTranslations("common");
const createCalendar = useCalendarStore((s) => s.createCalendar);
const [name, setName] = useState("");
const [color, setColor] = useState("#3b82f6");
const [isSubmitting, setIsSubmitting] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const isValid = name.trim().length > 0;
const handleSubmit = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) return;
setIsSubmitting(true);
try {
const created = await createCalendar(client, { name: trimmed, color });
if (created) {
toast.success(t("calendar_created"));
onClose();
} else {
toast.error(t("error_create"));
}
} catch {
toast.error(t("error_create"));
} finally {
setIsSubmitting(false);
}
}, [name, color, client, createCalendar, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isSubmitting) onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, isSubmitting]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
onClick={() => !isSubmitting && onClose()}
aria-hidden="true"
/>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("add_calendar")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
</div>
<button
onClick={onClose}
disabled={isSubmitting}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{tCommon("loading")}
</>
) : (
t("create")
)}
</Button>
</div>
</div>
</div>
);
}
+3
View File
@@ -35,6 +35,7 @@ interface EventModalProps {
calendars: Calendar[];
defaultDate?: Date;
defaultEndDate?: Date;
defaultCalendarId?: string;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial<CalendarEvent>) => void;
@@ -113,6 +114,7 @@ export function EventModal({
calendars,
defaultDate,
defaultEndDate,
defaultCalendarId,
onSave,
onDelete,
onDuplicate,
@@ -200,6 +202,7 @@ export function EventModal({
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
const [calendarId, setCalendarId] = useState<string>(() => {
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
const defaultCal = calendars.find(c => c.isDefault);
return defaultCal?.id || calendars[0]?.id || "";
});
+21 -8
View File
@@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
const handleBlur = useCallback(() => {
setTimeout(() => {
setShowSuggestions(false);
const trimmed = query.trim();
if (trimmed && EMAIL_REGEX.test(trimmed)) {
addParticipant({ name: "", email: trimmed });
}
}, 200);
const trimmed = query.trim();
if (trimmed && EMAIL_REGEX.test(trimmed)) {
addParticipant({ name: "", email: trimmed });
}
setTimeout(() => setShowSuggestions(false), 200);
}, [query, addParticipant]);
useImperativeHandle(ref, () => ({
@@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
key={`${p.email}-${i}`}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
>
<span className="truncate">{p.name || p.email}</span>
{!disabled ? (
<button
type="button"
onClick={() => {
onRemove(p.email);
setQuery(p.email);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
aria-label={`${t("edit")} ${p.name || p.email}`}
>
{p.name || p.email}
</button>
) : (
<span className="truncate">{p.name || p.email}</span>
)}
{!disabled && (
<button
type="button"
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
it('calls onDelete when delete button is clicked', () => {
const onDelete = vi.fn();
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
const deleteButton = screen.getAllByRole('button').find(
btn => btn.className.includes('text-red')
);
fireEvent.click(deleteButton!);
fireEvent.click(screen.getByLabelText('detail.more_actions'));
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
expect(onDelete).toHaveBeenCalledOnce();
});
});
@@ -36,7 +36,6 @@ const defaultProps = {
onSearchChange: vi.fn(),
onSelectContact: vi.fn(),
onCreateNew: vi.fn(),
categoryLabel: 'All Contacts',
selectedContactIds: new Set<string>(),
onToggleSelection: vi.fn(),
onSelectRangeContacts: vi.fn(),
@@ -79,8 +78,4 @@ describe('ContactList', () => {
expect(screen.getByText('bulk.export')).toBeInTheDocument();
});
it('shows category label with count', () => {
render(<ContactList {...defaultProps} />);
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
});
});
+117 -91
View File
@@ -2,12 +2,13 @@
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Mail, CalendarDays, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
const EMAIL_LIMIT = 5;
@@ -43,14 +44,15 @@ function buildEmailFilter(addresses: string[]): Record<string, unknown> {
}
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (!event.participants) return false;
for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true;
if (p.sendTo) {
for (const target of Object.values(p.sendTo)) {
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
if (event.participants) {
for (const p of Object.values(event.participants)) {
const email = p.email?.trim().toLowerCase();
if (email && addresses.has(email)) return true;
if (p.sendTo) {
for (const target of Object.values(p.sendTo)) {
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
}
}
}
@@ -62,6 +64,26 @@ function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boo
return false;
}
function getEmailSender(email: Email): { name: string; address: string } {
const from = email.from?.[0];
return {
name: from?.name?.trim() || from?.email || "",
address: from?.email || "",
};
}
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const groups = new Map<string, CalendarEvent[]>();
for (const e of events) {
const d = new Date(e.start);
const key = isNaN(d.getTime()) ? e.start : d.toISOString().slice(0, 10);
const arr = groups.get(key) || [];
arr.push(e);
groups.set(key, arr);
}
return groups;
}
export function ContactActivity({ contact }: ContactActivityProps) {
const t = useTranslations("contacts.activity");
const format = useFormatter();
@@ -170,111 +192,115 @@ export function ContactActivity({ contact }: ContactActivityProps) {
: { year: "numeric", month: "short", day: "numeric" });
};
const formatEventDate = (event: CalendarEvent) => {
const formatEventTime = (event: CalendarEvent) => {
if (event.showWithoutTime) return t("all_day");
const d = new Date(event.start);
if (isNaN(d.getTime())) return event.start;
if (event.showWithoutTime) {
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
}
return format.dateTime(d, {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
if (isNaN(d.getTime())) return "";
return format.dateTime(d, { hour: "numeric", minute: "2-digit" });
};
const formatEventDateHeader = (isoDate: string) => {
const d = new Date(isoDate);
if (isNaN(d.getTime())) return isoDate;
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
return format.dateTime(d, sameYear
? { weekday: "long", month: "long", day: "numeric" }
: { weekday: "long", year: "numeric", month: "long", day: "numeric" });
};
const eventGroups = events ? Array.from(groupEventsByDate(events).entries()) : [];
return (
<>
<ActivitySection icon={Mail} title={t("recent_emails")}>
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
<Section title={t("recent_emails")}>
{emailsLoading ? (
<LoadingRow />
) : emailsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !emails || emails.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
) : (
emails.map((email) => (
<button
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{email.subject || t("no_subject")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEmailDate(email.receivedAt)}
</span>
</div>
{email.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
</p>
)}
</button>
))
<div className="-mx-2">
{emails.map((email) => {
const sender = getEmailSender(email);
return (
<button
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<Avatar name={sender.name} email={sender.address} size="sm" />
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{sender.name || t("unknown_sender")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEmailDate(email.receivedAt)}
</span>
</div>
<div className="text-sm truncate">
{email.subject || t("no_subject")}
</div>
{email.preview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
</p>
)}
</div>
</button>
);
})}
</div>
)}
</ActivitySection>
</Section>
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
<Section title={t("upcoming_events")}>
{eventsLoading ? (
<LoadingRow />
) : eventsError ? (
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
) : !events || events.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
) : (
events.map((event) => (
<button
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium truncate">
{event.title || t("no_title")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEventDate(event)}
</span>
<div className="space-y-4">
{eventGroups.map(([dateKey, group]) => (
<div key={dateKey}>
<div className="text-xs font-medium text-muted-foreground mb-1.5">
{formatEventDateHeader(dateKey)}
</div>
<div className="-mx-2">
{group.map((event) => (
<button
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
{formatEventTime(event)}
</span>
<span className="text-sm truncate flex-1 min-w-0">
{event.title || t("no_title")}
</span>
</button>
))}
</div>
</div>
</button>
))
))}
</div>
)}
</ActivitySection>
</>
);
}
function ActivitySection({
icon: Icon,
title,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
}) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1 pl-6">{children}</div>
</Section>
</div>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
);
}
+63 -16
View File
@@ -11,16 +11,25 @@ import {
Eye,
Pencil,
Mail,
Phone,
ClipboardCopy,
Download,
Users,
Trash2,
Copy,
Printer,
} from "lucide-react";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
import { toast } from "@/stores/toast-store";
function getContactPrimaryPhone(contact: ContactCard): string {
if (!contact.phones) return "";
return Object.values(contact.phones)[0]?.number || "";
}
interface Position {
x: number;
y: number;
@@ -38,6 +47,7 @@ interface ContactContextMenuProps {
onEdit: () => void;
onDelete: () => void;
onAddToGroup: () => void;
onDuplicate?: () => void;
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
@@ -55,12 +65,14 @@ export function ContactContextMenu({
onEdit,
onDelete,
onAddToGroup,
onDuplicate,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
const showBatchActions = isMultiSelect && selectedCount > 1;
const handle = (fn: () => void) => () => {
@@ -73,10 +85,15 @@ export function ContactContextMenu({
window.location.href = `mailto:${email}`;
};
const handleCopyEmail = async () => {
if (!email) return;
const handleCall = () => {
if (!phone) return;
window.location.href = `tel:${phone}`;
};
const handleCopy = async (value: string) => {
if (!value) return;
try {
await navigator.clipboard.writeText(email);
await navigator.clipboard.writeText(value);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
@@ -88,6 +105,10 @@ export function ContactContextMenu({
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact);
};
if (showBatchActions) {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
@@ -122,20 +143,34 @@ export function ContactContextMenu({
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
{(email || phone) && <ContextMenuSeparator />}
{email && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
/>
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(handleCopyEmail)}
/>
</>
<ContextMenuItem
icon={Mail}
label={t("context_menu.send_email")}
onClick={handle(handleSendEmail)}
/>
)}
{phone && (
<ContextMenuItem
icon={Phone}
label={t("context_menu.call")}
onClick={handle(handleCall)}
/>
)}
{email && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_email")}
onClick={handle(() => handleCopy(email))}
/>
)}
{phone && (
<ContextMenuItem
icon={ClipboardCopy}
label={t("detail.copy_phone")}
onClick={handle(() => handleCopy(phone))}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
@@ -143,11 +178,23 @@ export function ContactContextMenu({
label={t("context_menu.add_to_group")}
onClick={handle(onAddToGroup)}
/>
{onDuplicate && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
/>
)}
<ContextMenuItem
icon={Download}
label={t("context_menu.export_vcard")}
onClick={handle(handleExport)}
/>
<ContextMenuItem
icon={Printer}
label={t("context_menu.print")}
onClick={handle(handlePrint)}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
+433 -292
View File
@@ -1,23 +1,37 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
type MoreItem =
| {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
destructive?: boolean;
separator?: false;
}
| { separator: true };
interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
isMobile?: boolean;
className?: string;
}
@@ -27,9 +41,45 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
return Object.keys(features).filter(k => features[k]).join(", ");
}
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
const d = new Date(dateInput.utc);
if (!isNaN(d.getTime())) {
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
return {};
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const parts = s.substring(2).split("-");
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
}
const d = new Date(s);
if (!isNaN(d.getTime())) {
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
return {};
}
function getCompletedYears(dateInput: AnniversaryDate): number | null {
const { year, month, day } = getDateParts(dateInput);
if (!year) return null;
const now = new Date();
let years = now.getFullYear() - year;
const m = month ?? 1;
const d = day ?? 1;
const nowM = now.getMonth() + 1;
const nowD = now.getDate();
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
if (years < 0) return null;
return years;
}
function formatDate(dateInput: AnniversaryDate): string {
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
if (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try {
@@ -41,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string {
return String(dateInput.utc);
}
const pd = dateInput as PartialDate;
const year = pd.year;
const month = pd.month;
const day = pd.day;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
if (day) parts.push(String(day));
if (year) parts.push(String(year));
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (pd.day) parts.push(String(pd.day));
if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) {
// Partial date without year
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
@@ -70,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string {
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
@@ -88,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
try {
let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) {
// data URI - extract base64 content
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1);
@@ -97,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
// PEM-encoded certificate inline
derBytes = key.uri;
}
if (!derBytes) continue;
@@ -128,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const photoUri = getContactPhotoUri(contact);
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact, name);
};
const moreItems: MoreItem[] = [];
if (onAddToGroup) {
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
}
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
moreItems.push({ separator: true });
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
@@ -171,257 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="flex items-center gap-4 min-w-0 flex-1">
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)}
{titleLine && (
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
)}
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
{subtitleParts.length > 0 && (
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
)}
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && (
<a
href={`mailto:${email}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
{t("detail.compose_email")}
</a>
)}
{phone && (
<a
href={`tel:${phone}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Phone className="w-4 h-4 mr-1" />
{t("context_menu.call")}
</a>
)}
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
<Trash2 className="w-4 h-4" />
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
</div>
</div>
</div>
<div className="px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<ContactActivity contact={contact} />
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
{hasContactDetails && (
<Section title={t("detail.section_contact")}>
<div className="space-y-3">
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address}
</a>
{e.contexts && <ContextBadge contexts={e.contexts} />}
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
<div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
{e.address}
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
<RowActions>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</div>
</FieldRow>
))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")} category="contact">
{phones.map((p, i) => {
const featureStr = formatPhoneFeatures(p.features);
const features = formatPhoneFeatures(p.features);
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
return (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && <ContextBadge contexts={p.contexts} />}
{featureStr && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
)}
<CopyButton
value={p.number}
label={t("detail.copy_phone")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
<RowActions>
<CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
);
})}
</Section>
)}
{(roles.length > 0 || jobTitles.length > 1) && (
<Section icon={Briefcase} title={t("detail.titles")} category="work">
{titles.map((tl, i) => (
<div key={i} className="text-sm flex items-center gap-2">
<span>{tl.name}</span>
{tl.kind && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
)}
</div>
))}
</Section>
)}
{orgs.length > 0 && (
<Section icon={Building} title={t("detail.organizations")} category="work">
{orgs.map((o, i) => (
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
</Section>
)}
{/* Addresses span full width */}
{addresses.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={MapPin} title={t("detail.addresses")} category="location">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{addresses.map((a, i) => (
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
<div>
{a.full || a.fullAddress
? (a.full || a.fullAddress)
: a.components && a.components.length > 0
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && <ContextBadge contexts={a.contexts} />}
</div>
{addresses.map((a, i) => {
const lines: string[] = [];
if (a.full || a.fullAddress) {
lines.push((a.full || a.fullAddress) as string);
} else if (a.components && a.components.length > 0) {
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
if (joined) lines.push(joined);
} else {
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
.map(s => (typeof s === "string" ? s.trim() : ""))
.filter(Boolean) as string[];
lines.push(...parts);
}
return (
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
<div className="text-sm space-y-0.5">
{lines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
{a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)}
</div>
))}
</div>
</Section>
</div>
)}
</FieldRow>
);
})}
{onlineServices.length > 0 && (
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
{svc.service && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
)}
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
<CopyButton
value={svc.user || svc.uri}
label={t("detail.copy_url")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
<FieldRow
key={`os${i}`}
icon={Globe}
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
>
<div className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
<RowActions>
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
))}
</Section>
)}
</div>
</Section>
)}
{anniversaries.length > 0 && (
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{formatDate(ann.date)}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{t(`detail.anniversary_${ann.kind}`)}
</span>
</div>
{hasWork && (
<Section title={t("detail.section_work")}>
<div className="space-y-3">
{orgs.map((o, i) => (
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
<div className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
</FieldRow>
))}
</Section>
)}
{personalInfo.length > 0 && (
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
{personalInfo.map((pi, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{pi.value}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
{pi.level && (
<span className="text-xs text-muted-foreground">({pi.level})</span>
)}
</div>
{titles.map((tl, i) => (
<FieldRow
key={`tl${i}`}
icon={Briefcase}
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
>
<div className="text-sm">{tl.name}</div>
</FieldRow>
))}
</Section>
)}
</div>
</Section>
)}
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
<div className="text-sm">
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
{contact.speakToAs.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</Section>
)}
{preferredLanguages.length > 0 && (
<Section icon={Languages} title={t("detail.languages")} category="personal">
{hasPersonal && (
<Section title={t("detail.section_personal")}>
<div className="space-y-3">
{anniversaries.map((ann, i) => {
const years = getCompletedYears(ann.date);
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
return (
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
<div className="text-sm">
{formatDate(ann.date)}
{years !== null && (
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)}
</div>
</FieldRow>
);
})}
{hasGender && (
<FieldRow icon={UserCircle} label={t("detail.gender")}>
<div className="text-sm">
{contact.speakToAs?.grammaticalGender && (
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
)}
{contact.speakToAs?.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</FieldRow>
)}
{preferredLanguages.map((lang, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{lang.language}</span>
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
</div>
<FieldRow
key={`lg${i}`}
icon={Languages}
label={formatContexts(lang.contexts) || t("detail.language_label")}
>
<div className="text-sm">{lang.language}</div>
</FieldRow>
))}
</Section>
)}
{personalInfo.map((pi, i) => (
<FieldRow
key={`pi${i}`}
icon={Heart}
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
>
<div className="text-sm">{pi.value}</div>
</FieldRow>
))}
</div>
</Section>
)}
{keywords.length > 0 && (
<Section icon={Tag} title={t("detail.categories")} category="digital">
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw}
</span>
))}
</div>
</Section>
)}
{keywords.length > 0 && (
<Section title={t("detail.categories")}>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw}
</span>
))}
</div>
</Section>
)}
{relatedTo.length > 0 && (
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
{relatedTo.length > 0 && (
<Section title={t("detail.related_contacts")}>
<div className="space-y-2">
{relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{uri}</span>
{relType && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
)}
</div>
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
<div className="text-sm break-all">{uri}</div>
</FieldRow>
);
})}
</Section>
)}
</div>
</Section>
)}
{cryptoKeys.length > 0 && (
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
{cryptoKeys.length > 0 && (
<Section title={t("detail.crypto_keys")}>
<div className="space-y-3">
{cryptoKeys.map((key, i) => {
const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
@@ -430,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
: false;
return (
<div key={i} className="p-3 rounded-lg border border-border space-y-1">
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? (
<>
<div className="flex items-center gap-2">
@@ -451,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div>
{!alreadyImported && (
<Button
variant="ghost"
size="sm"
className="ml-4 mt-1"
onClick={() => handleImportContactCert(i)}
>
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
<Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")}
</Button>
@@ -466,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
)}
</>
) : (
<div className="text-sm break-all">
<div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
@@ -479,91 +543,168 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</div>
);
})}
</Section>
)}
</div>
</Section>
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section title={t("detail.calendar")}>
<div className="space-y-3">
{contact.calendarUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.calendarUri}
</a>
</FieldRow>
)}
{contact.schedulingUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.schedulingUri}
</a>
</FieldRow>
)}
{contact.freeBusyUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
</div>
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.freeBusyUri}
</a>
</FieldRow>
)}
</Section>
)}
</div>
</Section>
)}
{/* Notes span full width */}
{notes.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
{notes.length > 0 && (
<Section title={t("detail.notes")}>
<div className="flex items-start gap-3">
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="text-sm space-y-2 flex-1 min-w-0">
{notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
))}
</Section>
</div>
</div>
)}
</Section>
)}
{/* Timestamps span full width */}
{(contact.created || contact.updated) && (
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
<ContactActivity contact={contact} />
{(contact.created || contact.updated) && (
<div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
</div>
</div>
);
}
function formatContexts(contexts?: Record<string, boolean>): string {
if (!contexts) return "";
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
}
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn("py-6", className)}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
{children}
</section>
);
}
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
{children}
</div>
</div>
);
}
function RowActions({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{children}
</div>
);
}
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (items.length === 0) return null;
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((o) => !o)}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
className="touch-manipulation"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
>
{items.map((item, i) => {
if (item.separator) {
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
}
return (
<button
key={i}
type="button"
role="menuitem"
onClick={() => {
item.onClick();
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
<span className="flex-1">{item.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const categoryStyles: Record<SectionCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
return (
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
<div className="flex items-center gap-2 mb-2.5">
<Icon className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<div className="space-y-1.5 pl-6">{children}</div>
</div>
);
}
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
const labels = Object.keys(contexts).filter(k => contexts[k]);
if (labels.length === 0) return null;
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
{labels.join(", ")}
</span>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return (
<button
@@ -575,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
toast.error(failMsg);
}
}}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
title={label}
aria-label={label}
>
+198 -74
View File
@@ -2,11 +2,12 @@
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
interface EmailEntry {
address: string;
@@ -49,59 +50,78 @@ interface ContactFormProps {
contact?: ContactCard | null;
addressBooks?: AddressBook[];
allKeywords?: string[];
defaultAddressBookId?: string;
onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void;
}
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const formCategoryStyles: Record<FormCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
category?: FormCategory;
}) {
const [open, setOpen] = useState(defaultOpen || !collapsible);
const [open, setOpen] = useState(defaultOpen);
return (
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
<section className="py-5">
<button
type="button"
className={cn(
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
collapsible && "hover:text-muted-foreground cursor-pointer",
!collapsible && "cursor-default"
"flex items-center gap-2 w-full text-left",
collapsible ? "cursor-pointer" : "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
tabIndex={collapsible ? 0 : -1}
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-left">{title}</span>
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{collapsible && (
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
{open && (
<div className="space-y-3 pt-3 pb-1">
{(open || !collapsible) && (
<div className="space-y-3 mt-3">
{children}
</div>
)}
</div>
</section>
);
}
const MAX_PHOTO_DIM = 512;
const PHOTO_QUALITY = 0.85;
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * ratio));
const h = Math.max(1, Math.round(img.height * ratio));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("canvas-unsupported"));
return;
}
ctx.drawImage(img, 0, 0, w, h);
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
resolve({ uri, mediaType: "image/jpeg" });
};
img.onerror = () => reject(new Error("invalid-image"));
img.src = reader.result as string;
};
reader.onerror = () => reject(new Error("read-failed"));
reader.readAsDataURL(file);
});
}
function Select({ value, onChange, children, className }: {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
@@ -124,11 +144,13 @@ function Select({ value, onChange, children, className }: {
);
}
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
function anniversaryDateToString(date: AnniversaryDate): string {
@@ -191,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
};
}
const [prefix, setPrefix] = useState(findComponent("prefix"));
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given"));
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname"));
const [suffix, setSuffix] = useState(findComponent("suffix"));
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState(
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
@@ -309,14 +331,61 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
return ids[0];
}
}
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
return defaultAddressBookId;
}
return "";
}, [contact]);
}, [contact, defaultAddressBookId, addressBooks]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
const initialPhotoEntry = useMemo(() => {
if (!contact?.media) return null;
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
}
return null;
}, [contact]);
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
const [photoError, setPhotoError] = useState<string | null>(null);
const [photoUploading, setPhotoUploading] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setPhotoError(t("photo_invalid"));
return;
}
if (file.size > 10 * 1024 * 1024) {
setPhotoError(t("photo_too_large"));
return;
}
setPhotoError(null);
setPhotoUploading(true);
try {
const { uri, mediaType } = await processImageFile(file);
setPhotoUri(uri);
setPhotoMediaType(mediaType);
} catch {
setPhotoError(t("photo_invalid"));
} finally {
setPhotoUploading(false);
}
};
const handlePhotoRemove = () => {
setPhotoUri(undefined);
setPhotoMediaType(undefined);
setPhotoError(null);
};
const validateEmail = (address: string): boolean => {
if (!address.trim()) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
@@ -373,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
phonesMap[`p${i}`] = obj;
});
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
@@ -427,6 +497,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
});
}
const mediaMap: Record<string, ContactMedia> = {};
if (contact?.media) {
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind !== "photo") mediaMap[key] = m;
}
}
if (photoUri) {
const photoKey = initialPhotoEntry?.key || "photo";
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
}
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
@@ -453,6 +534,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
@@ -466,6 +548,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
}
};
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
@@ -478,38 +563,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4">
<div className="px-6 py-4 max-w-3xl">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{/* Address Book Selector */}
{addressBooks && addressBooks.length > 1 && (
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
<div className="flex items-center gap-4 pb-4">
<button
type="button"
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
title={t("upload_photo")}
aria-label={t("upload_photo")}
>
<Avatar
name={previewName || undefined}
email={previewEmail || undefined}
contactPhotoUri={photoUri}
size="lg"
className="!w-20 !h-20 !text-xl"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
>
<Camera className="w-5 h-5" />
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
</span>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoSelect}
/>
</button>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
{photoError && (
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
)}
{photoUri && (
<button
type="button"
onClick={handlePhotoRemove}
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</FormSection>
<Trash2 className="w-3 h-3" />
{t("remove_photo")}
</button>
)}
</div>
</div>
<div className="divide-y divide-border/60">
{addressBooks && addressBooks.length > 1 && (
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</FormSection>
)}
{/* Name & Identity - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<FormSection icon={User} title={t("section_identity")}>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
@@ -543,10 +672,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</div>
</FormSection>
</div>
{/* Email */}
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
<FormSection icon={Mail} title={t("email")}>
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i}>
@@ -598,7 +726,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Phone */}
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
<FormSection icon={Phone} title={t("phone")}>
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
@@ -656,7 +784,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
@@ -677,9 +805,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Addresses - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
{/* Addresses */}
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
@@ -711,10 +838,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</Button>
</div>
</FormSection>
</div>
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
@@ -743,7 +869,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
<div className="space-y-2">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2">
@@ -775,7 +901,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
@@ -816,7 +942,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
<CategoryComboBox
keywordsStr={keywordsStr}
onChange={setKeywordsStr}
@@ -828,7 +954,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
@@ -849,7 +975,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
@@ -866,9 +992,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
</div>
</FormSection>
{/* Notes - full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
{/* Notes */}
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -876,7 +1001,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</FormSection>
</div>
</div>
</div>
+409 -56
View File
@@ -1,18 +1,98 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
type TriState = boolean | null;
interface ListFilters {
organization: string;
jobTitle: string;
location: string;
emailDomain: string;
birthdayMonth: number | null;
hasEmail: TriState;
hasPhone: TriState;
hasPhoto: TriState;
}
const EMPTY_FILTERS: ListFilters = {
organization: "",
jobTitle: "",
location: "",
emailDomain: "",
birthdayMonth: null,
hasEmail: null,
hasPhone: null,
hasPhoto: null,
};
function cycleTri(v: TriState): TriState {
return v === null ? true : v === true ? false : null;
}
function countActiveFilters(f: ListFilters): number {
let n = 0;
if (f.organization.trim()) n++;
if (f.jobTitle.trim()) n++;
if (f.location.trim()) n++;
if (f.emailDomain.trim()) n++;
if (f.birthdayMonth !== null) n++;
if (f.hasEmail !== null) n++;
if (f.hasPhone !== null) n++;
if (f.hasPhoto !== null) n++;
return n;
}
function matchTri(actual: boolean, filter: TriState): boolean {
if (filter === null) return true;
return actual === filter;
}
function getAnniversaryMonth(date: AnniversaryDate): number | null {
if (typeof date === "string") {
const iso = date.match(/^(\d{4})-(\d{2})/);
if (iso) return parseInt(iso[2], 10);
const partial = date.match(/^--(\d{2})/);
if (partial) return parseInt(partial[1], 10);
return null;
}
if ("month" in date && date.month) return date.month;
if ("utc" in date && date.utc) {
const d = new Date(date.utc);
return isNaN(d.getTime()) ? null : d.getMonth() + 1;
}
return null;
}
function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
value === true && "bg-primary/10 border-primary/30 text-primary",
value === false && "bg-muted border-border text-muted-foreground line-through",
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
)}
>
{icon}
{label}
</button>
);
}
interface ContactListProps {
contacts: ContactCard[];
selectedContactId: string | null;
@@ -20,7 +100,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void;
onCreateNew: () => void;
categoryLabel: string;
className?: string;
selectedContactIds: Set<string>;
onToggleSelection: (id: string) => void;
@@ -42,7 +121,6 @@ export function ContactList({
onSearchChange,
onSelectContact,
onCreateNew,
categoryLabel,
className,
selectedContactIds,
onToggleSelection,
@@ -57,31 +135,94 @@ export function ContactList({
onAddContactToGroup,
}: ContactListProps) {
const t = useTranslations("contacts");
const locale = useLocale();
const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
const activeFilters = countActiveFilters(filters);
const monthNames = useMemo(() => {
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
}, [locale]);
const filtered = useMemo(() => {
if (!searchQuery) return contacts;
const lower = searchQuery.toLowerCase();
const lower = searchQuery.trim().toLowerCase();
const orgLower = filters.organization.trim().toLowerCase();
const jobLower = filters.jobTitle.trim().toLowerCase();
const locLower = filters.location.trim().toLowerCase();
const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, "");
return contacts.filter((c) => {
const emails = c.emails ? Object.values(c.emails) : [];
const phones = c.phones ? Object.values(c.phones) : [];
const orgs = c.organizations ? Object.values(c.organizations) : [];
const titles = c.titles ? Object.values(c.titles) : [];
const addresses = c.addresses ? Object.values(c.addresses) : [];
const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : [];
if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false;
if (orgLower) {
const match = orgs.some((o) => {
if (o.name?.toLowerCase().includes(orgLower)) return true;
if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true;
return false;
});
if (!match) return false;
}
if (jobLower) {
if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false;
}
if (locLower) {
const match = addresses.some((a) => {
const parts: string[] = [];
if (a.full) parts.push(a.full);
if (a.fullAddress) parts.push(a.fullAddress);
if (a.locality) parts.push(a.locality);
if (a.region) parts.push(a.region);
if (a.country) parts.push(a.country);
if (a.postcode) parts.push(a.postcode);
if (a.street) parts.push(a.street);
if (a.components) {
for (const comp of a.components) {
if (comp.value) parts.push(comp.value);
}
}
return parts.some((p) => p.toLowerCase().includes(locLower));
});
if (!match) return false;
}
if (domainLower) {
const match = emails.some((e) => {
const at = e.address?.toLowerCase().split("@");
return at && at.length > 1 && at[1].includes(domainLower);
});
if (!match) return false;
}
if (filters.birthdayMonth !== null) {
const target = filters.birthdayMonth;
const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target);
if (!match) return false;
}
if (!lower) return true;
const name = getContactDisplayName(c).toLowerCase();
const emails = c.emails
? Object.values(c.emails).map((e) => e.address.toLowerCase())
: [];
const phones = c.phones
? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "")
: [];
const org = c.organizations
? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "")
: [];
return (
name.includes(lower) ||
emails.some((e) => e.includes(lower)) ||
phones.some((p) => p.includes(lower)) ||
org.some((o) => o.includes(lower))
);
if (name.includes(lower)) return true;
if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true;
if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true;
if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true;
return false;
});
}, [contacts, searchQuery]);
}, [contacts, searchQuery, filters]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
@@ -93,29 +234,214 @@ export function ContactList({
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
// Group sorted contacts by first letter of display name. Non-letter
// starters (digits, symbols, empty) collect under "#" which sorts last.
const groupedSections = useMemo(() => {
const collator = new Intl.Collator(locale, { sensitivity: "base" });
const groups = new Map<string, ContactCard[]>();
for (const contact of sorted) {
const name = getContactDisplayName(contact).trim();
const first = name.charAt(0);
const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u)
? first.toLocaleUpperCase(locale)
: "#";
const bucket = groups.get(letter);
if (bucket) bucket.push(contact);
else groups.set(letter, [contact]);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
if (a === "#") return 1;
if (b === "#") return -1;
return collator.compare(a, b);
})
.map(([letter, items]) => ({ letter, items }));
}, [sorted, locale]);
const hasSelection = selectedContactIds.size > 0;
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Search header */}
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground truncate">
{categoryLabel} ({contacts.length})
</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8 h-8 text-sm"
/>
{/* Toolbar: select / search / filter */}
<div className="border-b border-border bg-background">
<div className="px-3 py-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
if (hasSelection) {
if (allSelected) onClearSelection();
else onSelectAll(sortedIds);
} else if (sortedIds.length > 0) {
const target = selectedContactId && sortedIds.includes(selectedContactId)
? selectedContactId
: sortedIds[0];
onToggleSelection(target);
}
}}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
hasSelection
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
>
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
</button>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
/>
{searchQuery && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("clear_search")}
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
filtersOpen || activeFilters > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("filters.toggle")}
aria-label={t("filters.toggle")}
>
<Filter className="w-4 h-4" />
{!filtersOpen && activeFilters > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{activeFilters}
</span>
)}
</button>
</div>
</div>
</div>
{filtersOpen && (
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{t("filters.title")}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 mr-1" />
{t("filters.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setFiltersOpen(false)}
className="h-7 w-7"
aria-label={t("filters.close")}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.organization")}</label>
<Input
value={filters.organization}
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
placeholder={t("filters.organization_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
<Input
value={filters.jobTitle}
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
placeholder={t("filters.job_title_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
<Input
value={filters.location}
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
placeholder={t("filters.location_placeholder")}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
<Input
value={filters.emailDomain}
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
placeholder={t("filters.email_domain_placeholder")}
className="h-8 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
<select
value={filters.birthdayMonth ?? ""}
onChange={(e) => setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))}
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
aria-label={t("filters.birthday_month")}
>
<option value="">{t("filters.any_month")}</option>
{monthNames.map((name, i) => (
<option key={i} value={i + 1}>{name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<ToggleChip
icon={<Mail className="w-3.5 h-3.5" />}
label={t("filters.has_email")}
value={filters.hasEmail}
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
/>
<ToggleChip
icon={<Phone className="w-3.5 h-3.5" />}
label={t("filters.has_phone")}
value={filters.hasPhone}
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
/>
<ToggleChip
icon={<ImageIcon className="w-3.5 h-3.5" />}
label={t("filters.has_photo")}
value={filters.hasPhoto}
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
/>
</div>
</div>
</div>
)}
{/* Bulk action bar */}
{hasSelection && (
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
@@ -166,19 +492,32 @@ export function ContactList({
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
{searchQuery ? (
{searchQuery || activeFilters > 0 ? (
<>
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => onSearchChange("")}
>
{t("clear_search")}
</Button>
{activeFilters > 0 && !searchQuery ? (
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
) : (
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
)}
<p className="text-sm font-medium text-foreground">
{searchQuery ? t("empty_search") : t("empty_filtered")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
</p>
<div className="flex gap-2 mt-3">
{searchQuery && (
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
{t("clear_search")}
</Button>
)}
{activeFilters > 0 && (
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
{t("filters.clear")}
</Button>
)}
</div>
</>
) : (
<>
@@ -193,8 +532,8 @@ export function ContactList({
)}
</div>
) : (
<div>
{sorted.map((contact) => (
(() => {
const renderItem = (contact: ContactCard) => (
<ContactListItem
key={contact.id}
contact={contact}
@@ -221,8 +560,22 @@ export function ContactList({
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
/>
))}
</div>
);
return groupByLetter ? (
<div>
{groupedSections.map(({ letter, items }) => (
<section key={letter}>
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
{letter}
</div>
{items.map(renderItem)}
</section>
))}
</div>
) : (
<div>{sorted.map(renderItem)}</div>
);
})()
)}
</div>
+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();
}
+67 -20
View File
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
onRenameAddressBook?: (addressBook: AddressBook) => void;
onShareAddressBook?: (addressBook: AddressBook) => void;
onCreateContactInBook?: (addressBook: AddressBook) => void;
onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
}
@@ -62,6 +65,9 @@ export function ContactsSidebar({
onDropContacts,
onDropContactsToCategory,
onRenameAddressBook,
onShareAddressBook,
onCreateContactInBook,
onDeleteAddressBook,
onRenameKeyword,
className,
}: ContactsSidebarProps) {
@@ -288,7 +294,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -430,7 +436,7 @@ export function ContactsSidebar({
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
@@ -438,24 +444,65 @@ export function ContactsSidebar({
</div>
{/* Address book context menu */}
{bookContextMenu.data && onRenameAddressBook && (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
const book = bookContextMenu.data!;
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
</ContextMenu>
)}
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
const book = bookContextMenu.data;
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
const showSeparator = (canCreate || canRename || canShare) && canDelete;
return (
<ContextMenu
ref={bookMenuRef}
isOpen={bookContextMenu.isOpen}
position={bookContextMenu.position}
onClose={closeBookContextMenu}
>
{canCreate && (
<ContextMenuItem
icon={UserPlus}
label={t("address_books.new_contact_in_book")}
onClick={() => {
closeBookContextMenu();
onCreateContactInBook(book);
}}
/>
)}
{canRename && (
<ContextMenuItem
icon={Pencil}
label={t("address_books.rename")}
onClick={() => {
closeBookContextMenu();
onRenameAddressBook(book);
}}
/>
)}
{canShare && (
<ContextMenuItem
icon={Users}
label={t("address_books.share")}
onClick={() => {
closeBookContextMenu();
onShareAddressBook(book);
}}
/>
)}
{showSeparator && <ContextMenuSeparator />}
{canDelete && (
<ContextMenuItem
icon={Trash2}
label={t("address_books.delete")}
onClick={() => {
closeBookContextMenu();
onDeleteAddressBook(book);
}}
destructive
/>
)}
</ContextMenu>
);
})()}
{/* Keyword (category) context menu */}
{keywordContextMenu.data && onRenameKeyword && (
@@ -15,6 +15,7 @@ import {
X,
AlertCircle,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useTranslations, useFormatter } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
const [isCollapsed, setIsCollapsed] = useState(true);
const attachment = findCalendarAttachment(email);
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
const isCancellation = method === 'cancel';
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
const canCollapse = method === 'reply';
const showDetails = !canCollapse || !isCollapsed;
const allowsRsvp = method === 'request';
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
@@ -750,9 +754,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)}
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
</div>
{canCollapse && (
<button
type="button"
onClick={() => setIsCollapsed((prev) => !prev)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : t('collapse')}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
)}
</div>
{/* Content */}
{showDetails && (
<div className="px-4 py-3 space-y-2.5">
<div className="lg:flex lg:gap-6">
{/* Left: Event info */}
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</div>
)}
</div>
)}
{/* Actions */}
{showDetails && (
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
{canRespond && (
<>
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
)}
</div>
)}
</div>
);
}
+12 -1
View File
@@ -1100,8 +1100,14 @@ export function EmailComposer({
};
return (
<div className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
/>
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className={cn("flex flex-col h-full bg-background relative", className)}
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
data-tour="composer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -1674,6 +1680,11 @@ export function EmailComposer({
</div>
)}
</div>
<PluginSlot
name="composer-sidebar-right"
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
/>
</div>
);
}
+7 -3
View File
@@ -37,6 +37,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
@@ -49,6 +50,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
@@ -99,7 +101,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state
@@ -163,6 +166,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
)}
@@ -284,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
@@ -302,7 +306,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
+6 -5
View File
@@ -69,6 +69,7 @@ export function EmailList({
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
batchArchive,
batchMarkAsSpam,
batchUndoSpam,
loadMoreEmails,
@@ -497,12 +498,12 @@ export function EmailList({
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
if (!onArchive) return;
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
for (const email of selected) {
await onArchive(email);
if (!client) return;
try {
await batchArchive(client);
} catch (error) {
console.error('Failed to batch archive:', error);
}
clearSelection();
}}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => {
+174 -131
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
import ReactDOM from "react-dom";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -2322,7 +2322,7 @@ export function EmailViewer({
let blockedExternalContent = false;
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
const sanitizeConfig = { ...EMAIL_IFRAME_SANITIZE_CONFIG };
// Check if sender is trusted (localStorage list or address book)
const senderEmail = email.from?.[0]?.email?.toLowerCase();
@@ -2438,7 +2438,7 @@ export function EmailViewer({
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
);
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG);
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
}
if (smimeDecryptedText) {
@@ -2446,7 +2446,7 @@ export function EmailViewer({
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG);
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
}
if (tnefText) {
@@ -2454,7 +2454,7 @@ export function EmailViewer({
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG);
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
}
if (embeddedEmailText) {
@@ -2603,7 +2603,7 @@ export function EmailViewer({
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -2880,7 +2880,7 @@ export function EmailViewer({
<>
{/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -3436,70 +3436,172 @@ export function EmailViewer({
moreMenuOpen ? "translate-x-0" : "translate-x-full"
)}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
{moreMenuSub ? (
<button
onClick={() => setMoreMenuSub(null)}
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
<ChevronLeft className="w-5 h-5" />
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
</button>
) : (
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
)}
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto py-2">
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Archive className="w-5 h-5" />
{t('archive')}
</button>
{/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && (
{moreMenuSub === null && (
<>
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Archive className="w-5 h-5" />
{t('archive')}
</button>
{/* Move to folder (opens sub-view) */}
{moveTree.length > 0 && onMoveToMailbox && (
<button
onClick={() => setMoreMenuSub('move')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<FolderInput className="w-5 h-5" />
<span className="flex-1">{t('move_to')}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Tag (opens sub-view) */}
{colorOptions.length > 0 && (
<button
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
<div className="flex -space-x-1 mr-1">
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
})}
</div>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
)}
{/* Spam */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isInJunkFolder ? (
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Toggle read state */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Printer className="w-5 h-5" />
{t('print')}
</button>
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Code className="w-5 h-5" />
{t('view_source')}
</button>
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
</button>
)}
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
<div className="h-px bg-border my-1" />
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Download className="w-5 h-5" />
{t('export_email')}
</button>
<button
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Upload className="w-5 h-5" />
{t('import_email')}
</button>
{onShowShortcuts && (
<button
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Keyboard className="w-5 h-5" />
{t('keyboard_shortcuts')}
</button>
)}
</>
)}
{/* Tags */}
{colorOptions.length > 0 && (
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
@@ -3513,85 +3615,15 @@ export function EmailViewer({
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
<div className="h-px bg-border my-1" />
</>
)}
{/* Spam */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isInJunkFolder ? (
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Toggle read state */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Printer className="w-5 h-5" />
{t('print')}
</button>
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Code className="w-5 h-5" />
{t('view_source')}
</button>
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
</button>
)}
<div className="h-px bg-border my-1" />
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Download className="w-5 h-5" />
{t('export_email')}
</button>
<button
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Upload className="w-5 h-5" />
{t('import_email')}
</button>
{onShowShortcuts && (
<button
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Keyboard className="w-5 h-5" />
{t('keyboard_shortcuts')}
</button>
)}
</div>
</div>
)}
@@ -3628,7 +3660,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button
variant="ghost"
size="icon"
@@ -3676,8 +3708,8 @@ export function EmailViewer({
)}
</div>
</div>
{/* Date/time on the right of subject row */}
<div className="flex-shrink-0 text-right">
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-right">
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
@@ -4484,6 +4516,17 @@ export function EmailViewer({
)}
</div>
</div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-right ml-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/60">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
</div>
+14 -6
View File
@@ -64,6 +64,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
@@ -133,7 +135,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "bg-background"
),
selected && !resolvedColorTag && "shadow-sm",
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
@@ -181,6 +184,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
)}
@@ -305,7 +309,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{email.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
@@ -323,7 +327,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
@@ -358,6 +362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const isFocusedMailLayout = mailLayout === 'focus';
@@ -375,6 +380,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)).slice(0, 4)
: participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail,
@@ -487,7 +493,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50",
@@ -561,6 +568,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={avatarPerson?.email}
size="md"
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
/>
)}
@@ -707,7 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{latestEmail.subject || "(no subject)"}
</div>
{showPreview && density !== 'extra-compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
hasUnread
@@ -725,7 +733,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
CheckCheck,
MailOpen,
Mails,
Trash2,
FolderPlus,
Pencil,
FolderX,
RefreshCw,
} from "lucide-react";
interface Position {
x: number;
y: number;
}
export type MailboxContextTarget =
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
| { kind: "folders-section" };
interface MailboxContextMenuProps {
target: MailboxContextTarget | null;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefresh?: () => void;
}
export function MailboxContextMenu({
target,
position,
isOpen,
onClose,
menuRef,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefresh,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const handleAction = (action: () => void) => {
action();
onClose();
};
if (!target) return null;
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(onCreateFolder!)}
disabled={!onCreateFolder}
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
const mailbox = target.mailbox;
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
const isSystem =
!!mailbox.role &&
["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>{mailbox.name}</ContextMenuHeader>
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete}
destructive
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
</ContextMenu>
);
}
+5 -6
View File
@@ -8,7 +8,6 @@ import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { usePathname, Link, useRouter } from "@/i18n/navigation";
import NextLink from "next/link";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
@@ -336,9 +335,9 @@ export function NavigationRail({
);
})}
{/* Admin (Stalwart admins) */}
{/* Admin (Stalwart admins) — hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && (
<NextLink
<a
href="/admin"
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
@@ -348,7 +347,7 @@ export function NavigationRail({
>
<Shield className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</NextLink>
</a>
)}
{/* Settings */}
@@ -514,13 +513,13 @@ export function NavigationRail({
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{isStalwartAdmin && (
<NextLink
<a
href="/admin"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title={t("admin") || "Admin"}
>
<Shield className="w-[18px] h-[18px]" />
</NextLink>
</a>
)}
<Link
+64 -1
View File
@@ -31,6 +31,8 @@ import {
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
@@ -56,6 +58,15 @@ interface SidebarProps {
onCompose?: () => void;
onSidebarClose?: () => void;
onUnreadFilterClick?: (mailboxId: string) => void;
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: () => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
className?: string;
}
@@ -187,6 +198,7 @@ interface SidebarRowProps {
dropHandlers?: Record<string, unknown>;
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
}
function SidebarRow({
@@ -206,6 +218,7 @@ function SidebarRow({
dropHandlers,
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -213,6 +226,7 @@ function SidebarRow({
return (
<div
{...(dropHandlers || {})}
onContextMenu={onContextMenu}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -365,6 +379,7 @@ function MailboxTreeItem({
isCollapsed,
onUnreadFilterClick,
colorful,
onContextMenu,
}: {
node: MailboxNode;
selectedMailbox: string;
@@ -374,6 +389,7 @@ function MailboxTreeItem({
isCollapsed: boolean;
onUnreadFilterClick?: (mailboxId: string) => void;
colorful: boolean;
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
}) {
const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0;
@@ -423,6 +439,7 @@ function MailboxTreeItem({
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
isInvalidDropTarget={isInvalidDropTarget}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
@@ -436,6 +453,7 @@ function MailboxTreeItem({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful}
onContextMenu={onContextMenu}
/>
))}
</>
@@ -610,6 +628,15 @@ export function Sidebar({
onCompose: _onCompose,
onSidebarClose,
onUnreadFilterClick,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onRefreshMailboxes,
className,
}: SidebarProps) {
const router = useRouter();
@@ -790,6 +817,23 @@ export function Sidebar({
router.push('/settings');
};
const {
contextMenu: mailboxContextMenu,
openContextMenu: openMailboxContextMenu,
closeContextMenu: closeMailboxContextMenu,
menuRef: mailboxMenuRef,
} = useContextMenu<MailboxContextTarget>();
const handleMailboxContextMenu = (e: React.MouseEvent, node: MailboxNode) => {
const mailbox = mailboxes.find(mb => mb.id === node.id);
if (!mailbox) return;
openMailboxContextMenu(e, { kind: "mailbox", mailbox, hasChildren: node.children.length > 0 });
};
const handleFoldersHeaderContextMenu = (e: React.MouseEvent) => {
openMailboxContextMenu(e, { kind: "folders-section" });
};
return (
<div
className={cn(
@@ -866,7 +910,7 @@ export function Sidebar({
</div>
)}
<div>
<div onContextMenu={handleFoldersHeaderContextMenu}>
<SidebarSectionHeader
label={t("folders")}
expanded={foldersExpanded}
@@ -894,6 +938,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))
)}
@@ -934,6 +979,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))}
</div>
@@ -975,6 +1021,23 @@ export function Sidebar({
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
<MailboxContextMenu
target={mailboxContextMenu.data}
position={mailboxContextMenu.position}
isOpen={mailboxContextMenu.isOpen}
onClose={closeMailboxContextMenu}
menuRef={mailboxMenuRef}
onMarkFolderRead={onMarkFolderRead}
onMarkFolderTreeRead={onMarkFolderTreeRead}
onMarkAllFoldersRead={onMarkAllFoldersRead}
onEmptyFolder={onEmptyFolder}
onCreateSubfolder={onCreateSubfolder}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onRefresh={onRefreshMailboxes}
/>
</div>
);
}
+2
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json';
import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json';
@@ -20,6 +21,7 @@ import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
cs: csMessages,
en: enMessages,
fr: frMessages,
ja: jaMessages,
@@ -7,22 +7,21 @@ import { useConfig } from '@/hooks/use-config';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { usePolicyStore } from '@/stores/policy-store';
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
import { ExternalLink } from 'lucide-react';
import { SpamSiegeGame } from './spam-siege-game';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
export function AdvancedSettings() {
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const { debugMode, debugCategories, senderFavicons, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
const { settingsSyncEnabled } = useConfig();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const { isFeatureEnabled } = usePolicyStore();
const [showGame, setShowGame] = useState(false);
const logoClickCount = useRef(0);
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -86,7 +85,6 @@ export function AdvancedSettings() {
return (
<>
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
{/* About */}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
@@ -122,88 +120,48 @@ export function AdvancedSettings() {
</div>
</div>
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
)}
<SettingsSection title={t('title')} description={t('description')}>
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
{/* Debug Categories */}
{debugMode && !isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
key={cat.id}
label={t(`debug_categories.${cat.labelKey}`)}
description={t(`debug_categories.${cat.labelKey}_description`)}
>
<ToggleSwitch
checked={debugCategories?.[cat.id] !== false}
onChange={(checked) => {
updateSetting('debugCategories', {
...debugCategories,
[cat.id]: checked,
});
}}
/>
</SettingItem>
))}
</div>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
</SettingItem>
)}
{/* Sender Favicons (Experimental) */}
<SettingItem label={t('sender_favicons.label')} description={t('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
{/* Export Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
)}
{/* Import Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</>
</SettingItem>
)}
</SettingItem>
)}
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={handleImport}>
{t('import_settings.button')}
</Button>
</>
</SettingItem>
)}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
</>
);
}
@@ -2,13 +2,14 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag } from "lucide-react";
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
function AddressBookEditRow({
initial,
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{!book.isShared && book.myRights?.mayShare && (
<button
type="button"
onClick={() => setSharingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("share")}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
if (!book) return null;
return (
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</>
);
}
+11 -72
View File
@@ -2,15 +2,13 @@
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { useSettingsStore, type Density } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -66,16 +64,15 @@ function DensityPreview({ density }: { density: Density }) {
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tAdvanced = useTranslations('settings.advanced');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Theme */}
<SettingItem label={t('theme.label')} description={t('theme.description')}>
<RadioGroup
value={theme}
@@ -88,12 +85,6 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Language */}
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
{/* Font Size */}
{!isSettingHidden('fontSize') && (
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
<RadioGroup
@@ -108,7 +99,6 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Density */}
{!isSettingHidden('density') && (
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
<RadioGroup
@@ -127,64 +117,6 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Toolbar Position */}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
<RadioGroup
value={toolbarPosition}
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
options={[
{ value: 'top', label: t('toolbar_position.top') },
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
]}
/>
</SettingItem>
{/* Toolbar Labels */}
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
{/* Hide Account Switcher */}
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
<ToggleSwitch
checked={hideAccountSwitcher}
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
/>
</SettingItem>
{/* Show Rail Account List */}
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
<ToggleSwitch
checked={showRailAccountList}
onChange={(checked) => updateSetting('showRailAccountList', checked)}
/>
</SettingItem>
{/* Colorful Sidebar Icons */}
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
<ToggleSwitch
checked={colorfulSidebarIcons}
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
/>
</SettingItem>
{/* Unified Mailbox */}
{accounts.length > 1 && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
/>
</SettingItem>
)}
{/* Animations */}
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch
@@ -194,7 +126,14 @@ export function AppearanceSettings() {
</SettingItem>
)}
{/* Restart Tour */}
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
</SettingItem>
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button
variant="outline"
@@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
@@ -83,7 +85,7 @@ function CalendarColorPicker({
);
}
function CalendarEditForm({
export function CalendarEditForm({
initial,
onSave,
onCancel,
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
>
<Pencil className="w-3.5 h-3.5" />
</button>
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
<button
type="button"
onClick={() => setSharingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('share')}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => setClearingId(cal.id)}
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
onClose={() => setEditingSubscription(null)}
/>
)}
{sharingId && client && (() => {
const cal = calendars.find((c) => c.id === sharingId);
if (!cal) return null;
return (
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
}}
onClose={() => setSharingId(null)}
/>
);
})()}
</SettingsSection>
);
}
+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 { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem } from "./settings-section";
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
export function ContactsSettings() {
@@ -20,6 +21,8 @@ export function ContactsSettings() {
supportsSync,
importContacts,
} = useContactStore();
const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group");
@@ -55,6 +58,16 @@ export function ContactsSettings() {
title={tSettings("title")}
description={tSettings("description")}
>
<SettingItem
label={tSettings("group_by_letter_label")}
description={tSettings("group_by_letter_description")}
>
<ToggleSwitch
checked={groupContactsByLetter}
onChange={(checked) => updateSetting("groupContactsByLetter", checked)}
/>
</SettingItem>
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
@@ -0,0 +1,81 @@
"use client";
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
export function ContentSendersSettings() {
const t = useTranslations('settings.email_behavior');
const [showTrustedModal, setShowTrustedModal] = useState(false);
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const {
externalContentPolicy,
emailAlwaysLightMode,
trustedSenders,
trustedSendersAddressBook,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
const getTrustedSendersCount = () => {
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
};
return (
<SettingsSection title={t('title')} description={t('description')}>
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
)}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
<ToggleSwitch
checked={emailAlwaysLightMode}
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
/>
</SettingItem>
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
<button
onClick={() => setShowTrustedModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
</SettingItem>
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
<TrustedSendersModal
isOpen={showTrustedModal}
onClose={() => setShowTrustedModal(false)}
/>
</SettingsSection>
);
}
+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";
import { useState, useCallback } from 'react';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
];
function MailLayoutPreview({
value,
t,
}: {
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
<span className="text-muted-foreground/80">{row.preview}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function EmailSettings() {
export function ReadingSettings() {
const t = useTranslations('settings.email_behavior');
const { appName } = useConfig();
const [showTrustedModal, setShowTrustedModal] = useState(false);
const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const handleSetDefaultMailProgram = useCallback(() => {
try {
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
setDefaultMailStatus('success');
}
} catch {
setDefaultMailStatus('error');
}
}, []);
const [newKeyword, setNewKeyword] = useState('');
const {
markAsReadDelay,
deleteAction,
@@ -120,33 +25,17 @@ export function EmailSettings() {
showPreview,
mailLayout,
disableThreading,
autoSelectReplyIdentity,
plainTextMode,
emailsPerPage,
externalContentPolicy,
mailAttachmentAction,
attachmentPosition,
emailAlwaysLightMode,
archiveMode,
hoverActions,
hoverActionsMode,
hoverActionsCorner,
trustedSenders,
trustedSendersAddressBook,
attachmentReminderEnabled,
attachmentReminderKeywords,
hideInlineImageAttachments,
updateSetting,
} = useSettingsStore();
const { trustedSenderEmails } = useContactStore();
// Get count label for trusted senders button
const getTrustedSendersCount = () => {
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
if (count === 0) return t('trusted_senders.count_zero');
if (count === 1) return t('trusted_senders.count_one');
return t('trusted_senders.count_other', { count });
};
const isFocusedLayout = mailLayout === 'focus';
@@ -163,8 +52,6 @@ export function EmailSettings() {
try {
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
// Fetch all emails in the root archive mailbox
const emails = await client.getEmailsInMailbox(archiveId);
let movedCount = 0;
@@ -173,10 +60,8 @@ export function EmailSettings() {
const year = emailDate.getFullYear().toString();
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
// Re-read mailboxes from store each iteration in case new ones were created
let currentMailboxes = useEmailStore.getState().mailboxes;
// Find or create year subfolder
let yearMailbox = currentMailboxes.find(
m => m.name === year && m.parentId === archiveId
);
@@ -190,7 +75,6 @@ export function EmailSettings() {
await client.moveEmail(email.id, yearMailbox.id);
movedCount++;
} else {
// month mode
const yearId = yearMailbox.originalId || yearMailbox.id;
let monthMailbox = currentMailboxes.find(
m => m.name === month && m.parentId === yearId
@@ -215,7 +99,6 @@ export function EmailSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
{!isSettingHidden('markAsReadDelay') && (
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
<Select
@@ -231,7 +114,6 @@ export function EmailSettings() {
</SettingItem>
)}
{/* Delete Action */}
{!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2">
@@ -253,7 +135,6 @@ export function EmailSettings() {
</SettingItem>
)}
{/* Archive Mode */}
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
<div className="flex flex-col gap-2">
<Select
@@ -287,7 +168,6 @@ export function EmailSettings() {
</div>
</SettingItem>
{/* Permanently Delete Junk */}
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
<ToggleSwitch
checked={permanentlyDeleteJunk}
@@ -295,23 +175,6 @@ export function EmailSettings() {
/>
</SettingItem>
{!isSettingHidden('mailLayout') && (
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: t('mail_layout.split') },
{ value: 'focus', label: t('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={t} />
</div>
</SettingItem>
)}
{/* Show Preview */}
{!isSettingHidden('showPreview') && (
<SettingItem
label={t('show_preview.label')}
@@ -322,7 +185,6 @@ export function EmailSettings() {
</SettingItem>
)}
{/* Disable Thread Grouping */}
<SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}>
<ToggleSwitch
checked={disableThreading}
@@ -330,7 +192,6 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Plain Text Mode */}
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
@@ -338,71 +199,6 @@ export function EmailSettings() {
/>
</SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
{/* Attachment Reminder */}
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
/>
</SettingItem>
{attachmentReminderEnabled && (
<div className="py-3 border-b border-border space-y-2">
<div>
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{attachmentReminderKeywords.map((kw) => (
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
{kw}
<button
type="button"
aria-label={t('attachment_reminder.remove')}
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
const trimmed = newKeyword.trim().toLowerCase();
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
}
setNewKeyword('');
}}
>
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder={t('attachment_reminder.add_placeholder')}
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="submit"
disabled={!newKeyword.trim()}
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('attachment_reminder.add')}
</button>
</form>
</div>
)}
{/* Hide inline images from attachment list */}
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
<ToggleSwitch
checked={hideInlineImageAttachments}
@@ -410,7 +206,6 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
<div>
@@ -443,7 +238,6 @@ export function EmailSettings() {
})}
</div>
{/* Hover Actions Display Mode */}
<div className="pt-2 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label>
<div className="flex gap-2">
@@ -465,7 +259,6 @@ export function EmailSettings() {
</div>
</div>
{/* Corner Selection (only when floating) */}
{hoverActionsMode === 'floating' && (
<div className="pt-1 space-y-2">
<label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label>
@@ -513,7 +306,6 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Emails Per Page */}
{!isSettingHidden('emailsPerPage') && (
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
<Select
@@ -528,75 +320,6 @@ export function EmailSettings() {
/>
</SettingItem>
)}
{/* Always Light Mode for Emails */}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
<ToggleSwitch
checked={emailAlwaysLightMode}
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
/>
</SettingItem>
{/* External Content */}
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
options={[
{ value: 'ask', label: t('external_content.ask') },
{ value: 'block', label: t('external_content.block') },
{ value: 'allow', label: t('external_content.allow') },
]}
/>
</SettingItem>
)}
{/* Default Mail Program */}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
{/* Trusted Senders */}
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
<button
onClick={() => setShowTrustedModal(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
</SettingItem>
{/* Trusted Senders - address book storage */}
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
/>
</SettingItem>
{/* Trusted Senders Modal */}
<TrustedSendersModal
isOpen={showTrustedModal}
onClose={() => setShowTrustedModal(false)}
/>
</SettingsSection>
);
}
@@ -0,0 +1,347 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook";
type AnyRights = CalendarRights | AddressBookRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
read: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
readWrite: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
},
manager: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
};
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
return name;
}
}
return "custom";
}
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
interface ShareCollectionDialogProps {
client: IJMAPClient;
kind: ShareKind;
collectionName: string;
shareWith: Record<string, AnyRights> | null | undefined;
ownAccountId: string;
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
onClose: () => void;
}
export function ShareCollectionDialog({
client,
kind,
collectionName,
shareWith,
ownAccountId,
onShare,
onClose,
}: ShareCollectionDialogProps) {
const t = useTranslations("sharing");
const tCommon = useTranslations("common");
const modalRef = useRef<HTMLDivElement>(null);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
// Load principals on mount
useEffect(() => {
let cancelled = false;
setLoadingPrincipals(true);
client.getPrincipals().then((list) => {
if (cancelled) return;
// Exclude the user themselves and any principal that already has a share
const existing = new Set(Object.keys(shareWith || {}));
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
setPrincipals(filtered);
setLoadingPrincipals(false);
}).catch(() => {
if (!cancelled) setLoadingPrincipals(false);
});
return () => { cancelled = true; };
}, [client, ownAccountId, shareWith]);
// Map principal id -> Principal for displayed shares
const allPrincipalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
return map;
}, [principals]);
// Close on Escape, focus trap, click outside
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
const rights = kind === "calendar"
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
if (!rights) return;
setSavingId(principalId);
try {
await onShare(principalId, rights);
toast.success(t("share_updated"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleRemove = async (principalId: string) => {
setSavingId(principalId);
try {
await onShare(principalId, null);
toast.success(t("share_removed"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const handleAdd = async (principal: Principal) => {
const defaultPreset: RolePreset = "read";
const rights = kind === "calendar"
? CALENDAR_PRESETS[defaultPreset]
: ADDRESS_BOOK_PRESETS[defaultPreset];
setSavingId(principal.id);
try {
await onShare(principal.id, rights);
// Move principal out of the "to add" list
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
setShowAdd(false);
setSearch("");
toast.success(t("share_added"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("share_failed"));
} finally {
setSavingId(null);
}
};
const filteredPrincipals = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return principals;
return principals.filter((p) =>
p.name.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q)
);
}, [principals, search]);
const sharedEntries = useMemo(() => {
return Object.entries(shareWith || {}) as [string, AnyRights][];
}, [shareWith]);
const presetOptions = kind === "calendar"
? ["freeBusy", "read", "readWrite", "manager"] as const
: ["read", "readWrite", "manager"] as const;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title", { name: collectionName })}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{sharedEntries.length === 0 && !showAdd && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
{t("no_shares")}
</div>
)}
{sharedEntries.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
{sharedEntries.map(([principalId, rights]) => {
const principal = allPrincipalsById.get(principalId);
const preset = kind === "calendar"
? detectCalendarPreset(rights as CalendarRights)
: detectAddressBookPreset(rights as AddressBookRights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{principal?.name || principal?.email || principalId}
</div>
{principal?.description && (
<div className="text-xs text-muted-foreground truncate">
{principal.description}
</div>
)}
</div>
<div className="relative">
<select
value={preset}
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
disabled={savingId === principalId}
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{presetOptions.map((p) => (
<option key={p} value={p}>{t(`preset.${p}`)}</option>
))}
{preset === "custom" && (
<option value="custom">{t("preset.custom")}</option>
)}
</select>
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
</div>
<button
onClick={() => handleRemove(principalId)}
disabled={savingId === principalId}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label={t("remove")}
title={t("remove")}
>
{savingId === principalId
? <Loader2 className="w-4 h-4 animate-spin" />
: <Trash2 className="w-4 h-4" />}
</button>
</li>
);
})}
</ul>
)}
{!showAdd && (
<Button
variant="outline"
onClick={() => setShowAdd(true)}
className="w-full"
>
<UserPlus className="w-4 h-4 mr-2" />
{t("add_person")}
</Button>
)}
{showAdd && (
<div className="space-y-2 border border-border rounded-md p-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("search_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
<div className="max-h-48 overflow-y-auto -mx-1">
{loadingPrincipals && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
{t("loading_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
{search.trim() ? t("no_match") : t("no_principals")}
</div>
)}
{!loadingPrincipals && filteredPrincipals.map((p) => (
<button
key={p.id}
onClick={() => handleAdd(p)}
disabled={savingId === p.id}
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate flex items-center gap-2">
{p.name}
{p.type === "group" && (
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
{t("group")}
</span>
)}
</div>
{p.email && p.email !== p.name && (
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
)}
</div>
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
</div>
</button>
))}
</div>
<div className="flex justify-end pt-1">
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
{tCommon("cancel")}
</Button>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button onClick={onClose}>{tCommon("close")}</Button>
</div>
</div>
</div>
);
}
+8 -4
View File
@@ -140,9 +140,11 @@ interface AvatarProps {
contactPhotoUri?: string;
size?: "sm" | "md" | "lg";
className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean;
}
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -222,9 +224,11 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const imgSrc = disableImages
? null
: !imgError && !domainFailed
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const handleImgError = useCallback(() => {
// If the plugin avatar just failed, mark it and fall through to the next source
+16 -3
View File
@@ -45,9 +45,22 @@ export function FlagKR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
<rect width="3" height="2" fill="#fff" />
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
<path d="M1.5 0.5 a0.45 0.45 0 1 1 0 0.9 a0.45 0.45 0 1 0 0 -0.9" fill="#CD2E3A" />
<path d="M1.5 1.5 a0.45 0.45 0 1 1 0 -0.9 a0.45 0.45 0 1 0 0 0.9" fill="#0047A0" />
<circle cx="1.5" cy="0.8" r="0.225" fill="#0047A0" />
<circle cx="1.5" cy="1.2" r="0.225" fill="#CD2E3A" />
<g stroke="#000" strokeWidth="0.06" strokeLinecap="round">
<line x1="0.42" y1="0.35" x2="0.78" y2="0.35" />
<line x1="0.42" y1="0.46" x2="0.78" y2="0.46" />
<line x1="0.42" y1="0.57" x2="0.78" y2="0.57" />
<line x1="2.22" y1="0.35" x2="2.58" y2="0.35" />
<line x1="2.22" y1="0.57" x2="2.58" y2="0.57" />
<line x1="0.42" y1="1.43" x2="0.78" y2="1.43" />
<line x1="0.42" y1="1.65" x2="0.78" y2="1.65" />
<line x1="2.22" y1="1.43" x2="2.58" y2="1.43" />
<line x1="2.22" y1="1.54" x2="2.58" y2="1.54" />
<line x1="2.22" y1="1.65" x2="2.58" y2="1.65" />
</g>
</svg>
);
}
+1
View File
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons';
const languages = [
{ value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface PromptDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (value: string) => void;
title: string;
message?: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
}
export function PromptDialog({
isOpen,
onClose,
onSubmit,
title,
message,
placeholder,
defaultValue = "",
confirmText,
cancelText,
}: PromptDialogProps) {
const t = useTranslations("confirm_dialog");
const id = useId();
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setValue(defaultValue);
const t = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
return () => clearTimeout(t);
}
}, [isOpen, defaultValue]);
useEffect(() => {
if (!isOpen) return;
const handleBackdropClick = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleBackdropClick);
return () => document.removeEventListener("mousedown", handleBackdropClick);
}, [isOpen, onClose, dialogRef]);
if (!isOpen) return null;
const resolvedConfirmText = confirmText || t("confirm");
const resolvedCancelText = cancelText || t("cancel");
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!canSubmit) return;
try {
onSubmit(trimmed);
} finally {
onClose();
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<form onSubmit={handleSubmit}>
<div className="p-6">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
{message && (
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
)}
<Input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="mt-4"
/>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button type="button" variant="outline" onClick={onClose}>
{resolvedCancelText}
</Button>
<Button type="submit" variant="default" disabled={!canSubmit}>
{resolvedConfirmText}
</Button>
</div>
</form>
</div>
</div>
);
}
+9
View File
@@ -8,6 +8,11 @@ services:
- PORT=3000
env_file:
- .env.local
volumes:
# Encrypted user settings (SETTINGS_DATA_DIR).
- bulwark-settings:/app/data/settings
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
- bulwark-admin:/app/data/admin
healthcheck:
test:
[
@@ -23,3 +28,7 @@ services:
retries: 3
start_period: 10s
restart: unless-stopped
volumes:
bulwark-settings:
bulwark-admin:
+12 -16
View File
@@ -6,6 +6,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store";
import { getMailboxPath } from "@/lib/utils";
interface UseMailboxDropOptions {
mailbox: Mailbox;
@@ -30,7 +31,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
@@ -106,31 +107,26 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
const emailIds: string[] = JSON.parse(emailIdsJson);
// Get the destination mailbox ID (use originalId for shared folders)
const destinationId = mailbox.originalId || mailbox.id;
// Move emails one by one (store handles counter updates)
for (const emailId of emailIds) {
await moveToMailbox(client, emailId, destinationId);
}
// Move in a single bulk JMAP request (store handles counter updates).
await moveEmailsToMailbox(client, emailIds, mailbox.id);
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
clearSelection();
}
// Refresh the current mailbox view
await fetchEmails(client, selectedMailbox);
// Refresh the current mailbox view (honors active search/filters)
await refreshCurrentMailbox(client);
const mailboxPath = getMailboxPath(mailbox, mailboxes);
// Call success callback if provided, otherwise use fallback
if (onSuccess) {
onSuccess(emailIds.length, mailbox.name);
onSuccess(emailIds.length, mailboxPath);
} else {
// Fallback for backward compatibility
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
toast.success("Email moved", `Moved to ${mailboxPath}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`);
}
}
@@ -148,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+20 -16
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store";
// Tailwind v4 breakpoints
@@ -12,26 +12,30 @@ const BREAKPOINTS = {
"2xl": 1536,
} as const;
const getMediaQueryServerSnapshot = () => false;
/**
* SSR-safe media query hook
* Returns false during SSR to prevent hydration mismatch
* SSR-safe media query hook. On SSR and the first hydration pass we report
* `false`; on all subsequent client renders (including client-side navigation
* remounts) we read `matchMedia` synchronously, so components don't flash
* through a one-frame "mobile" layout on desktop.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
const subscribe = useCallback(
(callback: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
},
[query],
);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const getSnapshot = useCallback(
() => window.matchMedia(query).matches,
[query],
);
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [query]);
return matches;
return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
}
/**
+87
View File
@@ -0,0 +1,87 @@
import { useState, useCallback, useRef, useEffect } from "react";
interface PromptDialogState {
isOpen: boolean;
title: string;
message?: string;
placeholder?: string;
defaultValue: string;
confirmText?: string;
cancelText?: string;
onSubmit: (value: string) => void;
}
const INITIAL_STATE: PromptDialogState = {
isOpen: false,
title: "",
defaultValue: "",
onSubmit: () => {},
};
interface PromptOptions {
title: string;
message?: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
cancelText?: string;
}
export function usePromptDialog() {
const [state, setState] = useState<PromptDialogState>(INITIAL_STATE);
const resolveRef = useRef<((value: string | null) => void) | null>(null);
useEffect(() => {
return () => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
};
}, []);
const prompt = useCallback(
(options: PromptOptions): Promise<string | null> => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setState({
isOpen: true,
title: options.title,
message: options.message,
placeholder: options.placeholder,
defaultValue: options.defaultValue ?? "",
confirmText: options.confirmText,
cancelText: options.cancelText,
onSubmit: (value) => {
resolveRef.current = null;
resolve(value);
},
});
});
},
[]
);
const close = useCallback(() => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
setState(INITIAL_STATE);
}, []);
return {
dialogProps: {
isOpen: state.isOpen,
onClose: close,
onSubmit: state.onSubmit,
title: state.title,
message: state.message,
placeholder: state.placeholder,
defaultValue: state.defaultValue,
confirmText: state.confirmText,
cancelText: state.cancelText,
},
prompt,
};
}
+64 -22
View File
@@ -1,9 +1,10 @@
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
import { format } from "date-fns";
import { format, parseISO } from "date-fns";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
import type { Calendar } from "@/lib/jmap/types";
interface DragCreateState {
@@ -12,9 +13,13 @@ interface DragCreateState {
endMinutes: number;
}
export type ResizeEdge = "top" | "bottom";
interface ResizeState {
eventId: string;
topPx: number;
heightPx: number;
startMinutes: number;
durationMinutes: number;
}
@@ -40,6 +45,7 @@ interface UseTimeGridInteractionsOptions {
created: string;
error: string;
};
isMobile?: boolean;
}
export function useTimeGridInteractions({
@@ -47,6 +53,7 @@ export function useTimeGridInteractions({
calendars,
onCreateRange,
errorMessages,
isMobile,
}: UseTimeGridInteractionsOptions) {
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
const raw = ((clientY - containerTop) / hourHeight) * 60;
@@ -72,6 +79,8 @@ export function useTimeGridInteractions({
dayKey: string,
dayDate: Date,
) => {
if (isMobile) return;
if (e.pointerType === "touch") return;
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
@@ -82,7 +91,7 @@ export function useTimeGridInteractions({
dayKey, dayDate, startMinutes: minutes,
pointerId: e.pointerId, startY: e.clientY, captured: false,
};
}, [snapToMinutes]);
}, [snapToMinutes, isMobile]);
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
@@ -130,43 +139,65 @@ export function useTimeGridInteractions({
// --- Resize ---
const resizeRef = useRef<{
eventId: string;
edge: ResizeEdge;
startY: number;
originalStartMinutes: number;
originalDurationMinutes: number;
originalHeightPx: number;
pointerId: number;
} | null>(null);
const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null);
const computeResize = useCallback((
ref: NonNullable<typeof resizeRef.current>,
clientY: number,
): { startMinutes: number; durationMinutes: number } => {
const deltaY = clientY - ref.startY;
const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15;
const originalEnd = ref.originalStartMinutes + ref.originalDurationMinutes;
if (ref.edge === "bottom") {
const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes);
return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration };
}
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0.
let newStart = ref.originalStartMinutes + deltaMinutes;
newStart = Math.max(0, Math.min(originalEnd - 15, newStart));
return { startMinutes: newStart, durationMinutes: originalEnd - newStart };
}, [hourHeight]);
const handleResizePointerDown = useCallback((
eventId: string,
edge: ResizeEdge,
originalStartMinutes: number,
originalDurationMinutes: number,
e: PointerEvent,
) => {
e.stopPropagation();
e.preventDefault();
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
resizeRef.current = {
eventId,
edge,
startY: e.clientY,
originalStartMinutes,
originalDurationMinutes,
originalHeightPx,
pointerId: e.pointerId,
};
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [hourHeight]);
}, []);
const handleResizePointerMove = useCallback((e: PointerEvent) => {
if (!resizeRef.current) return;
const deltaY = e.clientY - resizeRef.current.startY;
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY);
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
const snappedHeight = (newDurationMinutes / 60) * hourHeight;
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes });
}, [hourHeight]);
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
setResizeVisual({
eventId: resizeRef.current.eventId,
topPx: (startMinutes / 60) * hourHeight,
heightPx: (durationMinutes / 60) * hourHeight,
startMinutes,
durationMinutes,
});
}, [hourHeight, computeResize]);
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
const resize = resizeRef.current;
@@ -179,11 +210,11 @@ export function useTimeGridInteractions({
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
const deltaY = e.clientY - resize.startY;
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
if (newDurationMinutes === resize.originalDurationMinutes) {
const startChanged = newStartMinutes !== resize.originalStartMinutes;
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
if (!startChanged && !durationChanged) {
setResizeVisual(null);
return;
}
@@ -205,14 +236,21 @@ export function useTimeGridInteractions({
try {
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined);
const updates: { start?: string; duration: string } = { duration: dur };
if (startChanged && event?.start) {
// Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone).
const deltaMinutes = newStartMinutes - resize.originalStartMinutes;
const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000);
updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
}
await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined);
} catch (error) {
debug.error("Failed to resize event:", resize.eventId, error);
toast.error(errorMessages.resize);
} finally {
setResizeVisual(null);
}
}, [hourHeight, errorMessages.resize]);
}, [computeResize, errorMessages.resize]);
// --- Click / Double-click / Quick-create ---
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -317,14 +355,18 @@ export function useTimeGridInteractions({
const minutes = snapDragMinutes(e);
const newStart = new Date(day);
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
// Emit `start` as a floating wall-clock in the event's own timeZone.
// If we used browser-local, the server would reinterpret it in event.timeZone
// and shift the event by the offset between the two zones.
const eventTimeZone = event?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const newStartISO = formatIsoInTimeZone(newStart, eventTimeZone);
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) {
toast.error(errorMessages.move);
return;
}
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch {
+3
View File
@@ -11,6 +11,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
// Use static imports for better compatibility
let messages;
switch (locale) {
case 'cs':
messages = (await import('../locales/cs/common.json')).default;
break;
case 'fr':
messages = (await import('../locales/fr/common.json')).default;
break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed';
export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
defaultLocale: 'en',
localePrefix
});
+1 -1
View File
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayAdmin: false,
mayShare: false,
mayDelete: false,
},
...overrides,
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import {
isValidFrameOrigin,
sanitizeFrameOrigins,
} from '@/lib/admin/csp-frame-origins';
describe('isValidFrameOrigin', () => {
it('accepts plain https origins', () => {
expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
});
it('accepts a wildcard subdomain', () => {
expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
});
it('accepts an explicit port', () => {
expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
});
it('rejects non-https schemes', () => {
expect(isValidFrameOrigin('http://example.com')).toBe(false);
expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
});
it('rejects bare schemes and wildcard hosts', () => {
expect(isValidFrameOrigin('https://')).toBe(false);
expect(isValidFrameOrigin('https://*')).toBe(false);
expect(isValidFrameOrigin('https://*.com')).toBe(false);
expect(isValidFrameOrigin('https://localhost')).toBe(false);
});
it('rejects paths, queries, and fragments', () => {
expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
expect(isValidFrameOrigin('https://example.com/')).toBe(false);
expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
});
it('rejects userinfo, IPs, and IPv6', () => {
expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
expect(isValidFrameOrigin('https://[::1]')).toBe(false);
});
it('rejects values that try to break out of the directive', () => {
expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
});
it('rejects non-strings and obvious garbage', () => {
expect(isValidFrameOrigin(undefined)).toBe(false);
expect(isValidFrameOrigin(null)).toBe(false);
expect(isValidFrameOrigin(42)).toBe(false);
expect(isValidFrameOrigin('')).toBe(false);
expect(isValidFrameOrigin('not-a-url')).toBe(false);
expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
});
});
describe('sanitizeFrameOrigins', () => {
it('returns empty for non-array input', () => {
expect(sanitizeFrameOrigins(undefined)).toEqual([]);
expect(sanitizeFrameOrigins(null)).toEqual([]);
expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
expect(sanitizeFrameOrigins({})).toEqual([]);
});
it('keeps valid entries and drops invalid ones silently', () => {
expect(
sanitizeFrameOrigins([
'https://www.youtube-nocookie.com',
'http://insecure.com',
'https://meet.example.com:8443',
'https://example.com/path',
42,
'https://*.vimeo.com',
]),
).toEqual([
'https://www.youtube-nocookie.com',
'https://meet.example.com:8443',
'https://*.vimeo.com',
]);
});
it('dedupes case-insensitively', () => {
expect(
sanitizeFrameOrigins([
'https://Example.com',
'https://example.com',
'https://EXAMPLE.com',
]),
).toEqual(['https://Example.com']);
});
});
+2
View File
@@ -47,6 +47,8 @@ function resetStore() {
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
'composer-sidebar': [],
'composer-sidebar-right': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookup = vi.fn();
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
describe('isPublicHttpUrl', () => {
beforeEach(() => {
lookup.mockReset();
});
afterEach(() => {
vi.resetModules();
});
async function load() {
const mod = await import('@/lib/security/url-guard');
return mod.isPublicHttpUrl;
}
it('accepts public https URLs whose DNS resolves to a public address', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://example.com/jmap')).toBe(true);
});
it('rejects malformed URLs', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('not a url')).toBe(false);
expect(await isPublicHttpUrl('')).toBe(false);
});
it('rejects non-http(s) protocols', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('file:///etc/passwd')).toBe(false);
expect(await isPublicHttpUrl('gopher://example.com/')).toBe(false);
expect(await isPublicHttpUrl('javascript:alert(1)')).toBe(false);
});
it('rejects URLs with embedded credentials', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://user:pass@example.com/')).toBe(false);
expect(await isPublicHttpUrl('https://user@example.com/')).toBe(false);
});
it('rejects loopback hostnames without DNS', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://localhost/')).toBe(false);
expect(await isPublicHttpUrl('http://service.localhost/')).toBe(false);
expect(await isPublicHttpUrl('http://server.local/')).toBe(false);
expect(await isPublicHttpUrl('http://kube.internal/api')).toBe(false);
expect(await isPublicHttpUrl('http://1.0.0.127.in-addr.arpa/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects literal IPv4 loopback and RFC-1918 ranges', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://127.0.0.1/')).toBe(false);
expect(await isPublicHttpUrl('http://10.0.0.5/')).toBe(false);
expect(await isPublicHttpUrl('http://10.255.255.255/')).toBe(false);
expect(await isPublicHttpUrl('http://172.16.0.1/')).toBe(false);
expect(await isPublicHttpUrl('http://172.31.255.254/')).toBe(false);
expect(await isPublicHttpUrl('http://192.168.1.1/')).toBe(false);
expect(await isPublicHttpUrl('http://0.0.0.0/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects literal AWS / GCP / Azure metadata IP', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://169.254.169.254/latest/meta-data/')).toBe(false);
expect(await isPublicHttpUrl('http://169.254.0.1/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects IPv6 loopback, ULA, and link-local literals', async () => {
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('http://[::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[::]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fc00::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fd12:3456::1]/')).toBe(false);
expect(await isPublicHttpUrl('http://[fe80::1]/')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects when DNS resolves to a private address (rebinding)', async () => {
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://evil.example.com/')).toBe(false);
});
it('rejects when any resolved address is private (mixed)', async () => {
lookup.mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '10.0.0.1', family: 4 },
]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://mixed.example.com/')).toBe(false);
});
it('rejects when DNS resolves to IPv6 loopback', async () => {
lookup.mockResolvedValue([{ address: '::1', family: 6 }]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://evil6.example.com/')).toBe(false);
});
it('rejects when DNS lookup throws', async () => {
lookup.mockRejectedValue(new Error('ENOTFOUND'));
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://nonexistent.example.com/')).toBe(false);
});
it('rejects when DNS returns no records', async () => {
lookup.mockResolvedValue([]);
const isPublicHttpUrl = await load();
expect(await isPublicHttpUrl('https://empty.example.com/')).toBe(false);
});
});
+72 -3
View File
@@ -34,11 +34,11 @@ describe("parseVCard", () => {
expect(result).toHaveLength(1);
const components = result[0].name?.components || [];
expect(components).toEqual([
{ kind: "prefix", value: "Mr." },
{ kind: "title", value: "Mr." },
{ kind: "given", value: "John" },
{ kind: "additional", value: "Michael" },
{ kind: "given2", value: "Michael" },
{ kind: "surname", value: "Doe" },
{ kind: "suffix", value: "Jr." },
{ kind: "generation", value: "Jr." },
]);
});
@@ -52,6 +52,21 @@ describe("parseVCard", () => {
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
});
it("maps prefix and middle name to RFC 9553 standard kinds (issue #224)", () => {
// N: family;given;additional;prefix;suffix (RFC 6350 order)
const withPrefix = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;Mr.;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
const c1 = withPrefix[0].name?.components || [];
expect(c1.find((c) => c.kind === "surname")?.value).toBe("Smith");
expect(c1.find((c) => c.kind === "given")?.value).toBe("John");
expect(c1.find((c) => c.kind === "title")?.value).toBe("Mr.");
const withMiddle = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;Mike;;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
const c2 = withMiddle[0].name?.components || [];
expect(c2.find((c) => c.kind === "surname")?.value).toBe("Smith");
expect(c2.find((c) => c.kind === "given")?.value).toBe("John");
expect(c2.find((c) => c.kind === "given2")?.value).toBe("Mike");
});
it("parses vCard with phone, org, and address", () => {
const vcf = [
"BEGIN:VCARD",
@@ -204,6 +219,60 @@ describe("parseVCard", () => {
expect(result[0].kind).toBe("group");
});
it("decodes ENCODING=QUOTED-PRINTABLE values with UTF-8 charset", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:2.1",
"N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Hans;;;",
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans M=C3=BCller",
"NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Caf=C3=A9 stra=C3=9Fe",
"EMAIL:hans@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
const components = card.name?.components || [];
expect(components.find((c) => c.kind === "given")?.value).toBe("Hans");
expect(components.find((c) => c.kind === "surname")?.value).toBe("Müller");
expect(card.notes?.n0?.note).toBe("Café straße");
});
it("joins QUOTED-PRINTABLE soft line breaks (= at end of line)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:2.1",
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans=20J=",
"=C3=BCrgen=20M=C3=BCller",
"EMAIL:hj@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const components = result[0].name?.components || [];
const given = components.find((c) => c.kind === "given")?.value;
const surname = components.find((c) => c.kind === "surname")?.value;
expect(given).toBe("Hans");
expect(surname).toBe("Jürgen Müller");
});
it("recognizes bare QUOTED-PRINTABLE encoding parameter (vCard 2.1 style)", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:2.1",
"FN;QUOTED-PRINTABLE;CHARSET=UTF-8:Caf=C3=A9",
"EMAIL:c@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const components = result[0].name?.components || [];
expect(components.find((c) => c.kind === "given")?.value).toBe("Café");
});
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
const vcf = [
"BEGIN:VCARD",
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookup = vi.fn();
vi.mock('node:dns/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:dns/promises')>();
return {
...actual,
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
lookup: (...args: unknown[]) => lookup(...args),
};
});
describe('verifyJmapAuth SSRF protection', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
lookup.mockReset();
vi.resetModules();
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
async function load() {
const mod = await import('@/lib/auth/verify-jmap-auth');
return mod;
}
it('rejects loopback literal without issuing fetch', async () => {
const { verifyJmapAuth, JmapAuthVerificationError } = await load();
await expect(verifyJmapAuth('http://127.0.0.1', 'Bearer x')).rejects.toBeInstanceOf(
JmapAuthVerificationError,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects AWS IMDS endpoint without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('http://169.254.169.254', 'Bearer x'),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects RFC-1918 literals without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
for (const target of ['http://10.0.0.5', 'http://172.16.0.1', 'http://192.168.1.1']) {
await expect(verifyJmapAuth(target, 'Bearer x')).rejects.toMatchObject({ status: 400 });
}
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects localhost hostname without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('http://localhost', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects IPv6 loopback literal without issuing fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('http://[::1]', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects hostnames whose DNS resolves to a private IP without issuing fetch', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://internal.example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rejects file:// URLs', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('file:///etc/passwd', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('refuses to follow a redirect to a private address', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/.well-known/jmap' } }),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('refuses to follow a redirect to AWS IMDS', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
}),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('accepts a public host that returns a valid JMAP session', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ apiUrl: 'https://example.com/api', accounts: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).resolves.toBe(
'https://example.com',
);
expect(fetchSpy).toHaveBeenCalledWith(
'https://example.com/.well-known/jmap',
expect.objectContaining({ redirect: 'manual' }),
);
});
it('rejects an invalid Authorization header before any fetch', async () => {
const { verifyJmapAuth } = await load();
await expect(verifyJmapAuth('https://example.com', 'NotAuth')).rejects.toMatchObject({
status: 400,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+16 -2
View File
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
@@ -64,6 +65,12 @@ class ConfigManager {
if (envVal !== undefined) {
return parseEnvValue(envVal, mapping.type) as T;
}
if (mapping.fileEnvVar) {
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
if (fileVal !== null) {
return parseEnvValue(fileVal, mapping.type) as T;
}
}
if (defaultValue !== undefined) return defaultValue;
return mapping.defaultValue as T;
}
@@ -94,9 +101,16 @@ class ConfigManager {
const envVal = process.env[mapping.envVar];
if (envVal !== undefined) {
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
} else {
result[key] = { value: mapping.defaultValue, source: 'default' };
continue;
}
if (mapping.fileEnvVar) {
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
if (fileVal !== null) {
result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'env' };
continue;
}
}
result[key] = { value: mapping.defaultValue, source: 'default' };
}
}
return result;
+102
View File
@@ -0,0 +1,102 @@
/**
* Computes the union of CSP `frame-src` origins declared by installed and
* enabled plugins. The proxy reads this on each request so that plugins can
* embed external content (YouTube, Vimeo, Jitsi, ) without us hard-coding
* domains in the host CSP.
*
* Origins are validated at install time and re-validated here as defense in
* depth any malformed value is dropped so a corrupted registry can never
* inject arbitrary CSP fragments.
*/
import { getPluginRegistry } from './plugin-registry';
// `https://host`, `https://host:port`, or `https://*.host[:port]`
//
// Each label is alphanumeric with optional inner dashes; the final TLD label
// MUST start with a letter so we reject raw IPv4 literals.
//
// Disallowed by the regex (intentionally):
// - any scheme other than https
// - paths, queries, fragments
// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
// - bare wildcards (`https://*`)
const FRAME_ORIGIN_RE =
/^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
export function isValidFrameOrigin(origin: unknown): origin is string {
if (typeof origin !== 'string') return false;
if (origin.length > 200) return false;
if (!FRAME_ORIGIN_RE.test(origin)) return false;
// Reject control characters / whitespace as a final safeguard against
// anything that would let an attacker break out of the directive.
if (/[\s'"`;,()]/.test(origin)) return false;
return true;
}
/**
* Sanitises a list of candidate origins from a manifest. Drops invalid
* entries silently and dedupes (case-insensitive on the host).
*/
export function sanitizeFrameOrigins(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const value of input) {
if (!isValidFrameOrigin(value)) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
}
return out;
}
// In-memory cache. The proxy fires on every page navigation; reading the
// registry JSON every time is fine but cheap to skip when nothing has
// changed. Five seconds is short enough to make plugin install/uninstall
// feel snappy without measurable overhead.
let cachedAt = 0;
let cachedOrigins: string[] = [];
const CACHE_TTL_MS = 5_000;
/**
* Returns the union of frame origins declared by every enabled plugin in
* the server-side registry, deduped and validated.
*
* Returns an empty array on any failure (missing file, parse error, ) so
* a broken registry only ever shrinks the CSP never widens it.
*/
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
const now = Date.now();
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
try {
const registry = await getPluginRegistry();
const seen = new Set<string>();
const out: string[] = [];
for (const plugin of registry.plugins) {
if (!plugin.enabled) continue;
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
for (const o of origins) {
const key = o.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(o);
}
}
cachedOrigins = out;
cachedAt = now;
return out;
} catch {
cachedOrigins = [];
cachedAt = now;
return [];
}
}
/** Force the next call to re-read the registry. Used by install/uninstall. */
export function invalidateFrameOriginsCache(): void {
cachedAt = 0;
cachedOrigins = [];
}
+5
View File
@@ -41,6 +41,11 @@ export interface ServerPlugin {
configSchema?: Record<string, PluginConfigField>;
installedAt: string;
updatedAt: string;
/**
* Validated CSP origins (https-only, single-origin form) the plugin may
* embed. Merged into the host frame-src by the proxy.
*/
frameOrigins?: string[];
}
export interface ServerTheme {
+3 -3
View File
@@ -106,7 +106,7 @@ export interface AuditEntry {
}
/** Config keys that map to environment variables */
export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
@@ -124,7 +124,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
@@ -134,7 +134,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
sessionSecret: { envVar: 'SESSION_SECRET', type: 'string', defaultValue: '' },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
};
/** Keys that should never be exposed to the client config endpoint */
+41 -6
View File
@@ -1,4 +1,7 @@
import { isPublicHttpUrl } from '@/lib/security/url-guard';
const VERIFY_TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 3;
export class JmapAuthVerificationError extends Error {
status: number;
@@ -41,15 +44,47 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
try {
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
});
let currentUrl = `${normalizedServerUrl}/.well-known/jmap`;
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isPublicHttpUrl(currentUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
response = await fetch(currentUrl, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
redirect: 'manual',
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
if (!response) {
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
}
if (response.status >= 300 && response.status < 400) {
throw new JmapAuthVerificationError('Too many redirects verifying JMAP session', 502);
}
if (!response.ok) {
throw new JmapAuthVerificationError(
@@ -77,4 +112,4 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
} finally {
clearTimeout(timeout);
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayAdmin: false,
mayShare: false,
mayDelete: false,
},
};
+38 -3
View File
@@ -219,10 +219,26 @@ export function layoutOverlappingEvents(
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
});
const columns: { event: CalendarEvent; end: number }[][] = [];
const result: TimedEventLayout[] = [];
let columns: { event: CalendarEvent; end: number }[][] = [];
let clusterStart = 0;
let clusterMaxEnd = 0;
const flushCluster = () => {
const total = columns.length;
for (let i = clusterStart; i < result.length; i++) {
result[i].totalColumns = total;
}
};
for (const event of sorted) {
if (columns.length > 0 && event.startMinutes >= clusterMaxEnd) {
flushCluster();
clusterStart = result.length;
columns = [];
clusterMaxEnd = 0;
}
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= event.startMinutes)) {
@@ -236,10 +252,10 @@ export function layoutOverlappingEvents(
columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
}
clusterMaxEnd = Math.max(clusterMaxEnd, event.endMinutes);
}
const total = columns.length;
result.forEach(r => r.totalColumns = total);
flushCluster();
return result;
}
@@ -256,3 +272,22 @@ export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): stri
export function getPrimaryCalendarId(event: Pick<CalendarEvent, 'calendarIds'>): string | undefined {
return Object.keys(event.calendarIds || {})[0];
}
export function formatIsoInTimeZone(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
const hour = map.hour === "24" ? "00" : map.hour;
return `${map.year}-${map.month}-${map.day}T${hour}:${map.minute}:${map.second}`;
}
+66 -1
View File
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
supportsCalendars(): boolean { return true; }
supportsSieve(): boolean { return true; }
supportsFiles(): boolean { return true; }
supportsPrincipals(): boolean { return false; }
async getPrincipals(): Promise<never[]> { return []; }
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
async setAddressBookShare(): Promise<void> { /* demo: no-op */ }
// ── Push / state ──────────────────────────────────────────────
@@ -269,6 +273,35 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
): Promise<void> {
if (emails.length === 0) return;
if (mode === 'single') {
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId);
return;
}
for (const { id, receivedAt } of emails) {
const email = this.data.emails.find(e => e.id === id);
if (!email) continue;
const d = new Date(receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId);
if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId);
let destId = yearBox.id;
if (mode === 'month') {
let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id);
if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id);
destId = monthBox.id;
}
email.mailboxIds = { [destId]: true };
}
this.recalcMailboxCounts();
}
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (email) email.mailboxIds = { [toMailboxId]: true };
@@ -283,6 +316,33 @@ export class DemoJMAPClient implements IJMAPClient {
return removed;
}
async markMailboxAsRead(mailboxId: string): Promise<number> {
let count = 0;
for (const email of this.data.emails) {
if (email.mailboxIds[mailboxId] && email.keywords.$seen !== true) {
email.keywords.$seen = true;
count++;
}
}
this.recalcMailboxCounts();
return count;
}
async markAllAsRead(excludeMailboxIds: string[] = []): Promise<number> {
const excluded = new Set(excludeMailboxIds);
let count = 0;
for (const email of this.data.emails) {
if (email.keywords.$seen === true) continue;
const mbIds = Object.keys(email.mailboxIds);
const onlyInExcluded = mbIds.length > 0 && mbIds.every(id => excluded.has(id));
if (onlyInExcluded) continue;
email.keywords.$seen = true;
count++;
}
this.recalcMailboxCounts();
return count;
}
async markAsSpam(emailId: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
@@ -496,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient {
if (book) Object.assign(book, updates);
}
async deleteAddressBook(addressBookId: string): Promise<void> {
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
return [...this.data.contacts];
@@ -553,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient {
includeInAvailability: 'all',
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
timeZone: null, shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
...calendar,
} as Calendar;
this.data.calendars.push(full);
+3 -3
View File
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
},
{
id: 'demo-calendar-work',
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
},
{
id: 'demo-calendar-birthdays',
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
},
];
}
+20
View File
@@ -36,6 +36,26 @@ export function sanitizeEmailHtml(html: string): string {
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
}
/**
* Sanitize config for emails rendered inside a sandboxed iframe.
* Allows <style> tags because CSS is scoped to the iframe document and
* cannot leak into the host app. Scripts are still blocked by the sandbox
* attribute (no allow-scripts). Use ONLY for iframe-rendered content
* never for content rendered into the main DOM.
*/
export const EMAIL_IFRAME_SANITIZE_CONFIG = {
...EMAIL_SANITIZE_CONFIG,
FORBID_TAGS: EMAIL_SANITIZE_CONFIG.FORBID_TAGS.filter((t) => t !== 'style'),
};
/**
* Sanitize email HTML for rendering inside a sandboxed iframe.
* Preserves <style> tags so the email's own CSS is applied.
*/
export function sanitizeEmailHtmlForIframe(html: string): string {
return DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
}
/**
* Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources
+17 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -85,8 +85,17 @@ export interface IJMAPClient {
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountId?: string,
): Promise<void>;
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
emptyMailbox(mailboxId: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
@@ -181,6 +190,7 @@ export interface IJMAPClient {
getAllAddressBooks(): Promise<AddressBook[]>;
createAddressBook(name: string): Promise<AddressBook>;
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
getContacts(addressBookId?: string): Promise<ContactCard[]>;
getAllContacts(): Promise<ContactCard[]>;
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
@@ -218,6 +228,12 @@ export interface IJMAPClient {
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
supportsPrincipals(): boolean;
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
+308 -2
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -1207,6 +1207,113 @@ export class JMAPClient implements IJMAPClient {
]);
}
async batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountId?: string,
): Promise<void> {
if (emails.length === 0) return;
const targetAccountId = accountId || this.accountId;
if (mode === 'single') {
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId);
return;
}
type Dest = { year: string; month?: string };
const destFor = new Map<string, Dest>();
for (const e of emails) {
const d = new Date(e.receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
}
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
const yearIdFor = new Map<string, string>();
const monthIdFor = new Map<string, string>();
const createEntries: Record<string, Record<string, unknown>> = {};
const findExisting = (name: string, parentId: string) =>
existingMailboxes.find(m =>
m.accountId === targetAccountId &&
m.name === name &&
(m.parentId === parentId || m.parentId === (parentId.startsWith('#') ? undefined : parentId)),
);
for (const dest of destFor.values()) {
if (!yearIdFor.has(dest.year)) {
const existing = findExisting(dest.year, archiveMailboxId);
if (existing) {
yearIdFor.set(dest.year, existing.originalId || existing.id);
} else {
const cid = `year-${dest.year}`;
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
yearIdFor.set(dest.year, `#${cid}`);
}
}
if (mode === 'month' && dest.month) {
const monthKey = `${dest.year}/${dest.month}`;
if (!monthIdFor.has(monthKey)) {
const yearRef = yearIdFor.get(dest.year)!;
// Only look up existing month folders under real (non-creation-ref) year ids.
const existingMonth = yearRef.startsWith('#')
? undefined
: findExisting(dest.month, yearRef);
if (existingMonth) {
monthIdFor.set(monthKey, existingMonth.originalId || existingMonth.id);
} else {
const cid = `month-${dest.year}-${dest.month}`;
createEntries[cid] = { name: dest.month, parentId: yearRef };
monthIdFor.set(monthKey, `#${cid}`);
}
}
}
}
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const [emailId, dest] of destFor.entries()) {
const destId = mode === 'month' && dest.month
? monthIdFor.get(`${dest.year}/${dest.month}`)!
: yearIdFor.get(dest.year)!;
updates[emailId] = { mailboxIds: { [destId]: true } };
}
const methodCalls: JMAPMethodCall[] = [];
const hasCreates = Object.keys(createEntries).length > 0;
if (hasCreates) {
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
const response = await this.request(methodCalls);
if (hasCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' ')}`);
}
}
const emailIdx = hasCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
}
}
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
@@ -1254,6 +1361,99 @@ export class JMAPClient implements IJMAPClient {
return totalDestroyed;
}
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
let totalMarked = 0;
let hasMore = true;
while (hasMore) {
const queryResponse = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: {
operator: "AND",
conditions: [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
],
},
limit: 500,
}, "0"],
]);
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
if (ids.length === 0) break;
const updates = Object.fromEntries(
ids.map((id) => [id, { "keywords/$seen": true }])
);
await this.request([
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
]);
totalMarked += ids.length;
hasMore = ids.length === 500;
}
return totalMarked;
}
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const excludeSet = new Set(excludeMailboxIds);
let totalMarked = 0;
let hasMore = true;
let position = 0;
while (hasMore) {
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: { notKeyword: "$seen" },
limit: 500,
position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: ["id", "mailboxIds"],
}, "1"],
]);
const queryResult = response.methodResponses?.[0]?.[1];
const getResult = response.methodResponses?.[1]?.[1];
const ids: string[] = queryResult?.ids || [];
const emails: Array<{ id: string; mailboxIds?: Record<string, boolean> }> = getResult?.list || [];
if (ids.length === 0) break;
const targetIds = excludeSet.size === 0
? ids
: emails
.filter(e => {
const mbIds = e.mailboxIds ? Object.keys(e.mailboxIds) : [];
return mbIds.some(id => !excludeSet.has(id));
})
.map(e => e.id);
if (targetIds.length > 0) {
const updates = Object.fromEntries(
targetIds.map((id) => [id, { "keywords/$seen": true }])
);
await this.request([
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
]);
totalMarked += targetIds.length;
}
hasMore = ids.length === 500;
position += ids.length;
}
return totalMarked;
}
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
@@ -1316,7 +1516,13 @@ export class JMAPClient implements IJMAPClient {
const result = response.methodResponses?.[0]?.[1];
if (result?.notCreated?.[createId]) {
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`);
const err = result.notCreated[createId];
const details = [err.type || 'unknown error'];
if (Array.isArray(err.properties) && err.properties.length > 0) {
details.push(`properties=[${err.properties.join(', ')}]`);
}
if (err.description) details.push(err.description);
throw new Error(`Failed to create mailbox: ${details.join(' ')}`);
}
const created = result?.created?.[createId];
@@ -2620,6 +2826,10 @@ export class JMAPClient implements IJMAPClient {
return this.hasCapability("urn:ietf:params:jmap:sieve");
}
supportsPrincipals(): boolean {
return this.hasCapability("urn:ietf:params:jmap:principals");
}
getSieveAccountId(): string {
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
return sieveAccount || this.accountId;
@@ -2992,6 +3202,102 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to update address book");
}
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
], this.contactUsing());
const result = response.methodResponses?.[0]?.[1];
if (result?.notDestroyed?.[addressBookId]) {
const err = result.notDestroyed[addressBookId];
throw new Error(err.description || "Failed to delete address book");
}
}
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
private principalsUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
}
/**
* List all principals visible to the user (RFC 9670). Stalwart returns the
* full directory regardless of `filter`, so we fetch the whole list and let
* callers filter client-side.
*/
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
if (!this.supportsPrincipals()) return [];
const accountId = targetAccountId || this.accountId;
try {
const response = await this.request([
["Principal/query", { accountId }, "0"],
["Principal/get", {
accountId,
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
}, "1"],
], this.principalsUsing());
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
if (!getResp) return [];
const list = (getResp[1].list || []) as Principal[];
return list.map((p) => ({ ...p, accountId }));
} catch (error) {
console.error("Failed to fetch principals:", error);
return [];
}
}
/**
* Add, update, or remove a principal's rights on a calendar.
* Pass `rights: null` to revoke access.
*/
async setCalendarShare(
calendarId: string,
principalId: string,
rights: CalendarRights | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
accountId,
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
}, "0"],
], this.calendarUsing());
const result = response.methodResponses?.[0]?.[1];
if (result?.notUpdated?.[calendarId]) {
const err = result.notUpdated[calendarId];
throw new Error(err.description || "Failed to update calendar share");
}
}
/**
* Add, update, or remove a principal's rights on an address book.
* Pass `rights: null` to revoke access.
*/
async setAddressBookShare(
addressBookId: string,
principalId: string,
rights: AddressBookRights | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || this.getContactsAccountId();
const response = await this.request([
["AddressBook/set", {
accountId,
update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } },
}, "0"],
], this.contactUsing());
const result = response.methodResponses?.[0]?.[1];
if (result?.notUpdated?.[addressBookId]) {
const err = result.notUpdated[addressBookId];
throw new Error(err.description || "Failed to update address book share");
}
}
private async fetchPaginatedContacts(
accountId: string,
filter?: Record<string, unknown>,
+15 -2
View File
@@ -368,6 +368,7 @@ export interface AddressBook {
isDefault?: boolean;
isSubscribed?: boolean;
myRights?: AddressBookRights;
shareWith?: Record<string, AddressBookRights> | null;
accountId?: string;
accountName?: string;
isShared?: boolean;
@@ -376,10 +377,22 @@ export interface AddressBook {
export interface AddressBookRights {
mayRead: boolean;
mayWrite: boolean;
mayShare: boolean;
mayShare?: boolean;
mayDelete: boolean;
}
// JMAP Principals (RFC 9670)
export interface Principal {
id: string;
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
name: string;
description?: string | null;
email?: string | null;
timeZone?: string | null;
capabilities?: Record<string, unknown>;
accountId?: string;
}
export interface VacationResponse {
id: string;
isEnabled: boolean;
@@ -442,7 +455,7 @@ export interface CalendarRights {
mayWriteOwn: boolean;
mayUpdatePrivate: boolean;
mayRSVP: boolean;
mayAdmin: boolean;
mayShare: boolean;
mayDelete: boolean;
}
+13 -7
View File
@@ -2,18 +2,23 @@ import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
function getClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
}
export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = process.env.OAUTH_ISSUER_URL;
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
}
const discoveryUrl = issuerUrl?.trim() || serverUrl;
if (issuerUrl !== undefined && !issuerUrl.trim()) {
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl };
@@ -36,8 +41,9 @@ export async function getMetadata(): Promise<OAuthMetadata | null> {
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig();
const params = new URLSearchParams({ ...base, client_id: clientId });
if (CLIENT_SECRET) {
params.set('client_secret', CLIENT_SECRET);
const secret = getClientSecret();
if (secret) {
params.set('client_secret', secret);
}
return params;
}
+7
View File
@@ -121,6 +121,7 @@ export interface PluginAPI {
registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
@@ -609,6 +610,12 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerComposerSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:composer-sidebar');
const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
return registerSlot(plugin.id, slot, widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerDetailSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
+18 -1
View File
@@ -41,6 +41,14 @@ export interface PluginManifest {
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record<string, Record<string, string>>;
/**
* External origins this plugin may embed in iframes (e.g. for YouTube,
* Vimeo, Jitsi). Each entry is a single CSP origin like
* "https://www.youtube-nocookie.com"
* "https://*.example.com:8443"
* Validated at install time and merged into the host CSP `frame-src`.
*/
frameOrigins?: string[];
}
export interface SettingFieldSchema {
@@ -101,6 +109,8 @@ export type SlotName =
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
| 'composer-sidebar'
| 'composer-sidebar-right'
| 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section'
@@ -150,6 +160,12 @@ export interface SidebarWidget {
label: string;
render: React.ComponentType;
order?: number;
/**
* For composer sidebars, choose which side of the New Message dialog the
* panel renders on. Defaults to `'left'` for backwards compatibility.
* Ignored by other sidebar slots.
*/
side?: 'left' | 'right';
}
export interface ContextMenuItem {
@@ -495,7 +511,8 @@ export const ALL_PERMISSIONS = [
'auth:observe',
'http:post',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
+67
View File
@@ -0,0 +1,67 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
const BLOCKED_HOSTNAMES = new Set(['localhost']);
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain'];
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4');
if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6');
return false;
}
/**
* Returns true only when the URL targets a public host reachable over http(s).
* Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname
* suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials,
* and any hostname whose DNS resolves to a blocked address.
*
* Note: there is a TOCTOU window between this lookup and the eventual fetch().
* Callers that need rebinding-safe behavior must additionally pin the resolved
* IP at connect time (e.g. via a custom undici dispatcher).
*/
export async function isPublicHttpUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (url.username || url.password) return false;
const hostname = normalizeHostname(url.hostname);
if (!hostname) return false;
if (BLOCKED_HOSTNAMES.has(hostname)) return false;
if (BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false;
if (isBlockedIpAddress(hostname)) return false;
if (isIP(hostname)) return true;
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) return false;
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
+19
View File
@@ -429,4 +429,23 @@ export function flattenMailboxTree(nodes: MailboxNode[]): MailboxNode[] {
traverse(nodes);
return result;
}
export function getMailboxPath(
mailbox: Pick<Mailbox, 'id' | 'name' | 'parentId'>,
allMailboxes: Array<Pick<Mailbox, 'id' | 'name' | 'parentId'>>,
separator = ' ',
): string {
const byId = new Map(allMailboxes.map(m => [m.id, m]));
const names: string[] = [mailbox.name];
const visited = new Set<string>([mailbox.id]);
let parentId = mailbox.parentId;
while (parentId && !visited.has(parentId)) {
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) break;
names.unshift(parent.name);
parentId = parent.parentId;
}
return names.join(separator);
}
+72 -11
View File
@@ -51,6 +51,53 @@ function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues
// onto the next line. This is distinct from RFC 5545/6350 line folding (which
// uses leading whitespace and is already handled in unfoldLines). Only merge
// when the originating line declares ENCODING=QUOTED-PRINTABLE so we don't
// accidentally splice unrelated lines.
function joinQpSoftBreaks(lines: string[]): string[] {
const result: string[] = [];
let i = 0;
while (i < lines.length) {
let line = lines[i];
if (/;ENCODING=QUOTED-PRINTABLE/i.test(line)) {
while (line.endsWith("=") && i + 1 < lines.length) {
i++;
line = line.slice(0, -1) + lines[i];
}
}
result.push(line);
i++;
}
return result;
}
function decodeQuotedPrintable(input: string, charset?: string): string {
const cleaned = input.replace(/=\r?\n/g, "");
const bytes: number[] = [];
let i = 0;
while (i < cleaned.length) {
const ch = cleaned[i];
if (ch === "=" && i + 2 < cleaned.length) {
const hex = cleaned.substring(i + 1, i + 3);
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
bytes.push(parseInt(hex, 16));
i += 3;
continue;
}
}
bytes.push(cleaned.charCodeAt(i) & 0xff);
i += 1;
}
const label = (charset || "utf-8").toLowerCase();
try {
return new TextDecoder(label).decode(new Uint8Array(bytes));
} catch {
return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
}
}
function decodeValue(raw: string): string {
return raw
.replace(/\\n/gi, "\n")
@@ -77,7 +124,9 @@ function parseParams(paramStr: string): Record<string, string> {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
} else {
const upper = part.toUpperCase();
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") {
params.ENCODING = upper;
} else if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
}
}
@@ -118,7 +167,7 @@ function contextToType(contexts: Record<string, boolean> | undefined): string {
export function parseVCard(vcfString: string): ContactCard[] {
const text = unfoldLines(vcfString);
const lines = text.split("\n");
const lines = joinQpSoftBreaks(text.split("\n"));
const contacts: ContactCard[] = [];
let current: Record<string, string[]> | null = null;
@@ -163,8 +212,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
const params = parseParams(paramStr);
const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
for (const rawValue of values) {
const val = decodeValue(rawValue);
const decoded = isQuotedPrintable
? decodeQuotedPrintable(rawValue, params.CHARSET)
: rawValue;
const val = decodeValue(decoded);
switch (propName) {
case "FN":
@@ -182,13 +236,17 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
case "N": {
// vCard N: family;given;additional;prefix;suffix (RFC 6350 §6.2.2)
// Mapped to JSContact-standard kinds (RFC 9553 §2.2.1):
// prefix→title, additional→given2, suffix→generation.
// Pushed in natural display order so `isOrdered: true` renders correctly.
const nParts = val.split(";");
const components: NameComponent[] = [];
if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] });
if (nParts[3]) components.push({ kind: "title", value: nParts[3] });
if (nParts[1]) components.push({ kind: "given", value: nParts[1] });
if (nParts[2]) components.push({ kind: "additional", value: nParts[2] });
if (nParts[2]) components.push({ kind: "given2", value: nParts[2] });
if (nParts[0]) components.push({ kind: "surname", value: nParts[0] });
if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] });
if (nParts[4]) components.push({ kind: "generation", value: nParts[4] });
if (components.length > 0) {
card.name = { components, isOrdered: true };
}
@@ -558,11 +616,14 @@ function generateSingleVCard(contact: ContactCard): string {
}
const components = contact.name?.components || [];
const given = components.find(c => c.kind === "given")?.value || "";
const surname = components.find(c => c.kind === "surname")?.value || "";
const prefix = components.find(c => c.kind === "prefix")?.value || "";
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
const findKind = (...kinds: string[]) =>
components.find(c => kinds.includes(c.kind))?.value || "";
const given = findKind("given");
const surname = findKind("surname");
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const prefix = findKind("title", "prefix");
const suffix = findKind("generation", "suffix");
const additional = findKind("given2", "additional", "middle");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
if (fn) {
File diff suppressed because it is too large Load Diff
+146 -7
View File
@@ -671,14 +671,24 @@
"files": "Dateien",
"contacts": "Kontakte",
"sidebar_apps": "Sidebar-Apps",
"notifications": "Benachrichtigungen"
"notifications": "Benachrichtigungen",
"layout": "Layout",
"reading": "Lesen",
"composing": "Verfassen",
"content_senders": "Inhalte & Absender",
"about_data": "Über & Daten",
"debug": "Debug"
},
"tab_groups": {
"general": "Allgemein",
"account": "Konto & Identität",
"organization": "E-Mail-Organisation",
"apps": "Apps",
"system": "System"
"system": "System",
"appearance": "Darstellung",
"mail": "E-Mail",
"privacy": "Datenschutz & Sicherheit",
"advanced": "Erweitert"
},
"appearance": {
"title": "Darstellung",
@@ -1275,6 +1285,10 @@
"label": "Absender-Favicons (Experimentell)",
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
},
"show_avatars_in_junk": {
"label": "Avatare im Spam-Ordner anzeigen",
"description": "Profilbilder und Favicons für Absender im Spam-Ordner anzeigen. Standardmäßig deaktiviert, damit Phishing-Versuche nicht durch vertraute Logos legitim wirken."
},
"keyboard_shortcuts": {
"label": "Tastaturkürzel",
"description": "Verfügbare Tastaturkürzel anzeigen",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten"
},
"mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren",
"mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren",
"mark_all_folders_read": "Alle Ordner als gelesen markieren",
"new_subfolder": "Neuer Unterordner...",
"new_folder": "Neuer Ordner...",
"rename": "Umbenennen...",
"empty_folder": "Ordner leeren",
"empty_folder_generic": "Ordner leeren",
"delete_folder": "Ordner löschen",
"refresh": "Aktualisieren",
"mark_all_confirm_title": "Alle Ordner als gelesen markieren",
"mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
"delete_confirm_title": "Ordner löschen",
"delete_confirm_message": "Den Ordner \"{name}\" dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
"prompt_new_subfolder": "Gib einen Namen für den neuen Unterordner ein.",
"prompt_new_folder": "Gib einen Namen für den neuen Ordner ein.",
"prompt_rename": "Gib einen neuen Namen für diesen Ordner ein.",
"placeholder_folder_name": "Ordnername",
"create": "Erstellen",
"rename_confirm": "Umbenennen",
"toast_marked_read": "Ordner als gelesen markiert",
"toast_marked_read_count": "{count, plural, one {1 Nachricht} other {# Nachrichten}} als gelesen markiert",
"toast_already_read": "Keine ungelesenen Nachrichten",
"toast_marked_all_read": "Alle Ordner als gelesen markiert",
"toast_emptied": "Ordner geleert",
"toast_folder_created": "Ordner erstellt",
"toast_folder_renamed": "Ordner umbenannt",
"toast_folder_deleted": "Ordner gelöscht",
"toast_error_mark_read": "Konnte nicht als gelesen markiert werden",
"toast_error_empty": "Ordner konnte nicht geleert werden",
"toast_error_create": "Ordner konnte nicht erstellt werden",
"toast_error_rename": "Ordner konnte nicht umbenannt werden",
"toast_error_delete": "Ordner konnte nicht gelöscht werden",
"toast_error_delete_has_children": "Ordner enthält Unterordner. Entferne diese zuerst.",
"toast_error_delete_has_email": "Ordner ist nicht leer. Leere ihn zuerst."
},
"shortcuts": {
"title": "Tastaturkürzel",
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
"empty_search": "Keine Kontakte gefunden",
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
"clear_search": "Suche löschen",
"import_vcard": "vCard importieren",
"delete_confirm_title": "Kontakt löschen",
@@ -1762,7 +1815,13 @@
"renamed": "Adressbuch umbenannt",
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
"default": "Standard",
"manage": "Adressbücher verwalten"
"manage": "Adressbücher verwalten",
"share": "Adressbuch freigeben",
"new_contact_in_book": "Neuer Kontakt in diesem Adressbuch",
"delete": "Adressbuch löschen",
"confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.",
"deleted": "Adressbuch gelöscht",
"delete_failed": "Adressbuch konnte nicht gelöscht werden"
},
"detail": {
"emails": "E-Mail-Adressen",
@@ -1814,7 +1873,22 @@
"import_to_smime": "In S/MIME importieren",
"cert_already_imported": "Zertifikat bereits importiert",
"cert_imported": "Zertifikat importiert",
"cert_import_failed": "Import des Zertifikats fehlgeschlagen"
"cert_import_failed": "Import des Zertifikats fehlgeschlagen",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Weitere Aktionen",
"age_years": "{count, plural, one {1 Jahr alt} other {# Jahre alt}}",
"years_since": "{count, plural, one {1 Jahr} other {# Jahre}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1897,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Neuer Kontakt",
@@ -1919,7 +1995,13 @@
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden",
"delete": "Löschen"
"delete": "Löschen",
"upload_photo": "Foto hochladen",
"remove_photo": "Foto entfernen",
"photo_hint": "JPG oder PNG, bis zu 10 MB. Wird verkleinert.",
"photo_too_large": "Bild ist zu groß (max. 10 MB)",
"photo_invalid": "Ungültige Bilddatei",
"change_photo": "Ändern"
},
"groups": {
"create": "Neue Gruppe",
@@ -1979,6 +2061,37 @@
"error_create": "Kontakt konnte nicht erstellt werden",
"error_update": "Kontakt konnte nicht aktualisiert werden",
"error_delete": "Kontakt konnte nicht gelöscht werden"
},
"context_menu": {
"open": "Öffnen",
"edit": "Bearbeiten",
"send_email": "E-Mail senden",
"add_to_group": "Zur Gruppe hinzufügen",
"export_vcard": "Als vCard exportieren",
"delete": "Löschen",
"call": "Anrufen",
"duplicate": "Duplizieren",
"print": "Drucken"
},
"filters": {
"toggle": "Filter",
"select": "Auswählen",
"clear": "Zurücksetzen",
"close": "Schließen",
"title": "Erweiterte Filter",
"organization": "Firma",
"organization_placeholder": "z. B. Acme GmbH",
"job_title": "Berufsbezeichnung",
"job_title_placeholder": "z. B. Designer",
"location": "Ort",
"location_placeholder": "Stadt oder Land",
"email_domain": "E-Mail-Domain",
"email_domain_placeholder": "beispiel.de",
"birthday_month": "Geburtstag im",
"any_month": "Beliebiger Monat",
"has_email": "Mit E-Mail",
"has_phone": "Mit Telefon",
"has_photo": "Mit Foto"
}
},
"calendar": {
@@ -2054,6 +2167,7 @@
"tentative": "Vorläufig",
"needs_action": "Antwort ausstehend",
"remove": "Entfernen",
"edit": "Bearbeiten",
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
"send_invitations": "Einladungen an Teilnehmer senden",
"status_summary": "{accepted} zugesagt, {pending} ausstehend",
@@ -2232,7 +2346,9 @@
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
"clear_events": "Ereignisse löschen",
"events_cleared": "{count} Ereignisse gelöscht",
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden"
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
"share": "Kalender freigeben",
"new_event_in_calendar": "Neuer Termin in diesem Kalender"
},
"subscription": {
"title": "iCal-Abonnement",
@@ -2598,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
},
"sharing": {
"title": "„{name}\" freigeben",
"description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.",
"no_shares": "Noch nicht freigegeben.",
"add_person": "Person oder Gruppe hinzufügen",
"search_placeholder": "Nach Name oder E-Mail suchen…",
"loading_principals": "Benutzer werden geladen…",
"no_principals": "Keine weiteren Benutzer oder Gruppen gefunden.",
"no_match": "Keine Treffer.",
"remove": "Zugriff entfernen",
"group": "Gruppe",
"share_added": "Zugriff erteilt",
"share_updated": "Zugriff aktualisiert",
"share_removed": "Zugriff entfernt",
"share_failed": "Freigabe konnte nicht aktualisiert werden",
"preset": {
"freeBusy": "Nur Frei/Belegt",
"read": "Nur lesen",
"readWrite": "Lesen & schreiben",
"manager": "Verwalten",
"custom": "Benutzerdefiniert"
}
}
}
+147 -9
View File
@@ -426,7 +426,9 @@
"cancel_info": "The organizer has cancelled this event.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Tentative",
"event_status_cancelled": "Cancelled"
"event_status_cancelled": "Cancelled",
"expand": "Show details",
"collapse": "Hide details"
},
"send": "Send",
"more": "more"
@@ -452,6 +454,9 @@
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"saving": "Saving...",
"sending": "Sending...",
"add_link": "Add link",
"link_url_prompt": "Enter the URL",
"draft_saved": "Draft saved",
"save_failed": "Failed to save",
"to_placeholder": "Recipient email addresses",
@@ -671,14 +676,24 @@
"contacts": "Contacts",
"encryption": "Encryption",
"sidebar_apps": "Sidebar Apps",
"notifications": "Notifications"
"notifications": "Notifications",
"layout": "Layout",
"reading": "Reading",
"composing": "Composing",
"content_senders": "Content & Senders",
"about_data": "About & Data",
"debug": "Debug"
},
"tab_groups": {
"general": "General",
"account": "Account & Identity",
"organization": "Mail Organization",
"apps": "Apps",
"system": "System"
"system": "System",
"appearance": "Appearance",
"mail": "Mail",
"privacy": "Privacy & Security",
"advanced": "Advanced"
},
"appearance": {
"title": "Appearance",
@@ -1275,6 +1290,10 @@
"label": "Sender Favicons (Experimental)",
"description": "Show website icons as profile pictures for business senders"
},
"show_avatars_in_junk": {
"label": "Show Avatars in Junk Folder",
"description": "Show profile images and favicons for senders in the junk folder. Disabled by default to avoid lending visual legitimacy to phishing attempts."
},
"keyboard_shortcuts": {
"label": "Keyboard Shortcuts",
"description": "View available keyboard shortcuts",
@@ -1310,6 +1329,8 @@
"contacts": {
"title": "Contacts",
"description": "Import and export your contacts",
"group_by_letter_label": "Group by first letter",
"group_by_letter_description": "Show alphabetical section headers in the contact list",
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
@@ -1576,6 +1597,43 @@
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first."
},
"shortcuts": {
"title": "Keyboard Shortcuts",
"tip": "Press ? anytime to show this help",
@@ -1737,6 +1795,8 @@
"empty_state_subtitle": "Create your first contact or import from a vCard file",
"empty_search": "No contacts match your search",
"empty_search_hint": "Try a different search term",
"empty_filtered": "No contacts match your filters",
"empty_filtered_hint": "Try adjusting or clearing filters",
"clear_search": "Clear search",
"import_vcard": "Import vCard",
"delete_confirm_title": "Delete contact",
@@ -1762,7 +1822,13 @@
"renamed": "Address book renamed",
"rename_failed": "Failed to rename address book",
"default": "Default",
"manage": "Manage address books"
"manage": "Manage address books",
"share": "Share address book",
"new_contact_in_book": "New contact in this address book",
"delete": "Delete address book",
"confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.",
"deleted": "Address book deleted",
"delete_failed": "Failed to delete address book"
},
"detail": {
"emails": "Email Addresses",
@@ -1814,7 +1880,22 @@
"calendar": "Calendar",
"calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling URL",
"freebusy_uri": "Free/Busy URL"
"freebusy_uri": "Free/Busy URL",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "More actions",
"age_years": "{count, plural, one {1 year old} other {# years old}}",
"years_since": "{count, plural, one {1 year} other {# years}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1904,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "New Contact",
@@ -1919,7 +2002,13 @@
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact",
"delete": "Delete"
"delete": "Delete",
"upload_photo": "Upload photo",
"remove_photo": "Remove photo",
"photo_hint": "JPG or PNG, up to 10 MB. Will be resized.",
"photo_too_large": "Image is too large (max 10 MB)",
"photo_invalid": "Invalid image file",
"change_photo": "Change"
},
"groups": {
"create": "New Group",
@@ -1986,7 +2075,30 @@
"send_email": "Send email",
"add_to_group": "Add to group",
"export_vcard": "Export as vCard",
"delete": "Delete"
"delete": "Delete",
"call": "Call",
"duplicate": "Duplicate",
"print": "Print"
},
"filters": {
"toggle": "Filters",
"select": "Select",
"clear": "Clear",
"close": "Close",
"title": "Advanced filters",
"organization": "Company",
"organization_placeholder": "e.g. Acme Corp",
"job_title": "Job title",
"job_title_placeholder": "e.g. Designer",
"location": "Location",
"location_placeholder": "City or country",
"email_domain": "Email domain",
"email_domain_placeholder": "example.com",
"birthday_month": "Birthday in",
"any_month": "Any month",
"has_email": "Has email",
"has_phone": "Has phone",
"has_photo": "Has photo"
}
},
"calendar": {
@@ -2062,6 +2174,7 @@
"tentative": "Tentative",
"needs_action": "Needs action",
"remove": "Remove",
"edit": "Edit",
"email_placeholder": "Add email address or search contacts",
"send_invitations": "Send invitations to participants",
"status_summary": "{accepted} accepted, {pending} pending",
@@ -2240,7 +2353,9 @@
"error_delete": "Failed to delete calendar",
"caldav_url": "CalDAV URL",
"copy_url": "Copy CalDAV URL",
"url_copied": "CalDAV URL copied to clipboard"
"url_copied": "CalDAV URL copied to clipboard",
"share": "Share calendar",
"new_event_in_calendar": "New event in this calendar"
},
"subscription": {
"title": "iCal Subscription",
@@ -2322,6 +2437,29 @@
"overdue": "Overdue"
}
},
"sharing": {
"title": "Share \"{name}\"",
"description": "Grant access to other users or groups on this server. Changes take effect immediately.",
"no_shares": "Not shared with anyone yet.",
"add_person": "Add person or group",
"search_placeholder": "Search by name or email…",
"loading_principals": "Loading users…",
"no_principals": "No other users or groups found.",
"no_match": "No matches.",
"remove": "Remove access",
"group": "Group",
"share_added": "Access granted",
"share_updated": "Access updated",
"share_removed": "Access removed",
"share_failed": "Failed to update sharing",
"preset": {
"freeBusy": "Free/busy only",
"read": "Read only",
"readWrite": "Read & write",
"manager": "Manager",
"custom": "Custom"
}
},
"advanced_search": {
"title": "Advanced Search",
"from": "From",
+146 -7
View File
@@ -671,14 +671,24 @@
"files": "Archivos",
"contacts": "Contactos",
"sidebar_apps": "Apps de barra lateral",
"notifications": "Notificaciones"
"notifications": "Notificaciones",
"layout": "Diseño",
"reading": "Lectura",
"composing": "Redacción",
"content_senders": "Contenido y remitentes",
"about_data": "Acerca de y datos",
"debug": "Depuración"
},
"tab_groups": {
"general": "General",
"account": "Cuenta e identidad",
"organization": "Organización del correo",
"apps": "Aplicaciones",
"system": "Sistema"
"system": "Sistema",
"appearance": "Apariencia",
"mail": "Correo",
"privacy": "Privacidad y seguridad",
"advanced": "Avanzado"
},
"appearance": {
"title": "Apariencia",
@@ -1275,6 +1285,10 @@
"label": "Favicons de remitente (Experimental)",
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
},
"show_avatars_in_junk": {
"label": "Mostrar avatares en la carpeta de spam",
"description": "Mostrar imágenes de perfil y favicons de remitentes en la carpeta de spam. Desactivado por defecto para no dar apariencia legítima a los intentos de phishing."
},
"keyboard_shortcuts": {
"label": "Atajos de Teclado",
"description": "Ver atajos de teclado disponibles",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": {
"title": "Atajos de Teclado",
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
"empty_search": "Ningún contacto coincide con tu búsqueda",
"empty_search_hint": "Prueba con otro término de búsqueda",
"empty_filtered": "Ningún contacto coincide con tus filtros",
"empty_filtered_hint": "Ajusta o limpia los filtros",
"clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard",
"delete_confirm_title": "Eliminar contacto",
@@ -1762,7 +1815,13 @@
"renamed": "Libreta de direcciones renombrada",
"rename_failed": "Error al renombrar la libreta de direcciones",
"default": "Predeterminada",
"manage": "Administrar libretas de direcciones"
"manage": "Administrar libretas de direcciones",
"share": "Compartir libreta de direcciones",
"new_contact_in_book": "Nuevo contacto en esta libreta",
"delete": "Eliminar libreta de direcciones",
"confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.",
"deleted": "Libreta de direcciones eliminada",
"delete_failed": "No se pudo eliminar la libreta de direcciones"
},
"detail": {
"emails": "Direcciones de correo",
@@ -1814,7 +1873,22 @@
"import_to_smime": "Importar a S/MIME",
"cert_already_imported": "Certificado ya importado",
"cert_imported": "Certificado importado",
"cert_import_failed": "Error al importar el certificado"
"cert_import_failed": "Error al importar el certificado",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Más acciones",
"age_years": "{count, plural, one {1 año} other {# años}}",
"years_since": "{count, plural, one {hace 1 año} other {hace # años}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1897,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nuevo contacto",
@@ -1919,7 +1995,13 @@
"email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto",
"delete": "Eliminar"
"delete": "Eliminar",
"upload_photo": "Subir foto",
"remove_photo": "Quitar foto",
"photo_hint": "JPG o PNG, hasta 10 MB. Se redimensionará.",
"photo_too_large": "La imagen es demasiado grande (máx. 10 MB)",
"photo_invalid": "Archivo de imagen no válido",
"change_photo": "Cambiar"
},
"groups": {
"create": "Nuevo grupo",
@@ -1979,6 +2061,37 @@
"error_create": "Error al crear el contacto",
"error_update": "Error al actualizar el contacto",
"error_delete": "Error al eliminar el contacto"
},
"context_menu": {
"open": "Abrir",
"edit": "Editar",
"send_email": "Enviar correo",
"add_to_group": "Añadir al grupo",
"export_vcard": "Exportar como vCard",
"delete": "Eliminar",
"call": "Llamar",
"duplicate": "Duplicar",
"print": "Imprimir"
},
"filters": {
"toggle": "Filtros",
"select": "Seleccionar",
"clear": "Borrar",
"close": "Cerrar",
"title": "Filtros avanzados",
"organization": "Empresa",
"organization_placeholder": "p. ej. Acme",
"job_title": "Puesto",
"job_title_placeholder": "p. ej. Diseñador",
"location": "Ubicación",
"location_placeholder": "Ciudad o país",
"email_domain": "Dominio de correo",
"email_domain_placeholder": "ejemplo.com",
"birthday_month": "Cumpleaños en",
"any_month": "Cualquier mes",
"has_email": "Con correo",
"has_phone": "Con teléfono",
"has_photo": "Con foto"
}
},
"calendar": {
@@ -2054,6 +2167,7 @@
"tentative": "Provisional",
"needs_action": "Pendiente de respuesta",
"remove": "Eliminar",
"edit": "Editar",
"email_placeholder": "Añadir dirección de correo o buscar contactos",
"send_invitations": "Enviar invitaciones a los participantes",
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
@@ -2232,7 +2346,9 @@
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
"clear_events": "Borrar eventos",
"events_cleared": "{count} eventos borrados",
"error_clear": "No se pudieron borrar los eventos del calendario"
"error_clear": "No se pudieron borrar los eventos del calendario",
"share": "Compartir calendario",
"new_event_in_calendar": "Nuevo evento en este calendario"
},
"subscription": {
"title": "Suscripción iCal",
@@ -2598,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
},
"sharing": {
"title": "Compartir «{name}»",
"description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.",
"no_shares": "Aún no se ha compartido con nadie.",
"add_person": "Añadir persona o grupo",
"search_placeholder": "Buscar por nombre o correo…",
"loading_principals": "Cargando usuarios…",
"no_principals": "No se han encontrado otros usuarios ni grupos.",
"no_match": "Sin resultados.",
"remove": "Quitar acceso",
"group": "Grupo",
"share_added": "Acceso concedido",
"share_updated": "Acceso actualizado",
"share_removed": "Acceso retirado",
"share_failed": "No se pudo actualizar el uso compartido",
"preset": {
"freeBusy": "Solo disponibilidad",
"read": "Solo lectura",
"readWrite": "Lectura y escritura",
"manager": "Administrador",
"custom": "Personalizado"
}
}
}
+146 -7
View File
@@ -671,14 +671,24 @@
"files": "Fichiers",
"contacts": "Contacts",
"sidebar_apps": "Apps de la barre latérale",
"notifications": "Notifications"
"notifications": "Notifications",
"layout": "Mise en page",
"reading": "Lecture",
"composing": "Rédaction",
"content_senders": "Contenu et expéditeurs",
"about_data": "À propos et données",
"debug": "Débogage"
},
"tab_groups": {
"general": "Général",
"account": "Compte & Identité",
"organization": "Organisation des e-mails",
"apps": "Applications",
"system": "Système"
"system": "Système",
"appearance": "Apparence",
"mail": "Courrier",
"privacy": "Confidentialité et sécurité",
"advanced": "Avancé"
},
"appearance": {
"title": "Apparence",
@@ -1275,6 +1285,10 @@
"label": "Favicons des expéditeurs (Expérimental)",
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
},
"show_avatars_in_junk": {
"label": "Afficher les avatars dans le dossier indésirable",
"description": "Afficher les photos de profil et favicons des expéditeurs dans le dossier indésirable. Désactivé par défaut pour éviter de donner une apparence légitime aux tentatives d'hameçonnage."
},
"keyboard_shortcuts": {
"label": "Raccourcis clavier",
"description": "Voir les raccourcis clavier disponibles",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": {
"title": "Raccourcis clavier",
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
"empty_search": "Aucun contact ne correspond à votre recherche",
"empty_search_hint": "Essayez un autre terme de recherche",
"empty_filtered": "Aucun contact ne correspond à vos filtres",
"empty_filtered_hint": "Ajustez ou effacez les filtres",
"clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard",
"delete_confirm_title": "Supprimer le contact",
@@ -1762,7 +1815,13 @@
"renamed": "Carnet d'adresses renommé",
"rename_failed": "Échec du renommage du carnet d'adresses",
"default": "Par défaut",
"manage": "Gérer les carnets d'adresses"
"manage": "Gérer les carnets d'adresses",
"share": "Partager le carnet d'adresses",
"new_contact_in_book": "Nouveau contact dans ce carnet d'adresses",
"delete": "Supprimer le carnet d'adresses",
"confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.",
"deleted": "Carnet d'adresses supprimé",
"delete_failed": "Échec de la suppression du carnet d'adresses"
},
"detail": {
"emails": "Adresses e-mail",
@@ -1814,7 +1873,22 @@
"import_to_smime": "Importer dans S/MIME",
"cert_already_imported": "Certificat déjà importé",
"cert_imported": "Certificat importé",
"cert_import_failed": "Échec de l'import du certificat"
"cert_import_failed": "Échec de l'import du certificat",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Plus d'actions",
"age_years": "{count, plural, one {1 an} other {# ans}}",
"years_since": "{count, plural, one {il y a 1 an} other {il y a # ans}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1897,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nouveau contact",
@@ -1919,7 +1995,13 @@
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact",
"delete": "Supprimer"
"delete": "Supprimer",
"upload_photo": "Téléverser une photo",
"remove_photo": "Supprimer la photo",
"photo_hint": "JPG ou PNG, jusqu’à 10 Mo. Sera redimensionnée.",
"photo_too_large": "Limage est trop grande (max 10 Mo)",
"photo_invalid": "Fichier image invalide",
"change_photo": "Modifier"
},
"groups": {
"create": "Nouveau groupe",
@@ -1979,6 +2061,37 @@
"error_create": "Échec de la création du contact",
"error_update": "Échec de la mise à jour du contact",
"error_delete": "Échec de la suppression du contact"
},
"context_menu": {
"open": "Ouvrir",
"edit": "Modifier",
"send_email": "Envoyer un courriel",
"add_to_group": "Ajouter au groupe",
"export_vcard": "Exporter en vCard",
"delete": "Supprimer",
"call": "Appeler",
"duplicate": "Dupliquer",
"print": "Imprimer"
},
"filters": {
"toggle": "Filtres",
"select": "Sélectionner",
"clear": "Effacer",
"close": "Fermer",
"title": "Filtres avancés",
"organization": "Entreprise",
"organization_placeholder": "ex. Acme",
"job_title": "Poste",
"job_title_placeholder": "ex. Designer",
"location": "Lieu",
"location_placeholder": "Ville ou pays",
"email_domain": "Domaine e-mail",
"email_domain_placeholder": "exemple.com",
"birthday_month": "Anniversaire en",
"any_month": "Tous les mois",
"has_email": "Avec e-mail",
"has_phone": "Avec téléphone",
"has_photo": "Avec photo"
}
},
"calendar": {
@@ -2054,6 +2167,7 @@
"tentative": "Provisoire",
"needs_action": "En attente de réponse",
"remove": "Retirer",
"edit": "Modifier",
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
"send_invitations": "Envoyer les invitations aux participants",
"status_summary": "{accepted} accepté(s), {pending} en attente",
@@ -2232,7 +2346,9 @@
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
"clear_events": "Supprimer les événements",
"events_cleared": "{count} événements supprimés",
"error_clear": "Impossible de supprimer les événements du calendrier"
"error_clear": "Impossible de supprimer les événements du calendrier",
"share": "Partager le calendrier",
"new_event_in_calendar": "Nouvel événement dans ce calendrier"
},
"subscription": {
"title": "Abonnement iCal",
@@ -2598,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
},
"sharing": {
"title": "Partager « {name} »",
"description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.",
"no_shares": "Pas encore partagé.",
"add_person": "Ajouter une personne ou un groupe",
"search_placeholder": "Rechercher par nom ou e-mail…",
"loading_principals": "Chargement des utilisateurs…",
"no_principals": "Aucun autre utilisateur ou groupe trouvé.",
"no_match": "Aucun résultat.",
"remove": "Révoquer l'accès",
"group": "Groupe",
"share_added": "Accès accordé",
"share_updated": "Accès mis à jour",
"share_removed": "Accès révoqué",
"share_failed": "Échec de la mise à jour du partage",
"preset": {
"freeBusy": "Disponibilité uniquement",
"read": "Lecture seule",
"readWrite": "Lecture & écriture",
"manager": "Gestionnaire",
"custom": "Personnalisé"
}
}
}
+146 -7
View File
@@ -671,14 +671,24 @@
"files": "File",
"contacts": "Contatti",
"sidebar_apps": "App nella barra laterale",
"notifications": "Notifiche"
"notifications": "Notifiche",
"layout": "Layout",
"reading": "Lettura",
"composing": "Composizione",
"content_senders": "Contenuto e mittenti",
"about_data": "Informazioni e dati",
"debug": "Debug"
},
"tab_groups": {
"general": "Generale",
"account": "Account e identità",
"organization": "Organizzazione e-mail",
"apps": "Applicazioni",
"system": "Sistema"
"system": "Sistema",
"appearance": "Aspetto",
"mail": "Posta",
"privacy": "Privacy e sicurezza",
"advanced": "Avanzate"
},
"appearance": {
"title": "Aspetto",
@@ -1275,6 +1285,10 @@
"label": "Favicon dei mittenti (Sperimentale)",
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
},
"show_avatars_in_junk": {
"label": "Mostra avatar nella cartella spam",
"description": "Mostra immagini profilo e favicon dei mittenti nella cartella spam. Disattivato per impostazione predefinita per non dare apparenza legittima ai tentativi di phishing."
},
"keyboard_shortcuts": {
"label": "Scorciatoie da tastiera",
"description": "Visualizza le scorciatoie da tastiera disponibili",
@@ -1576,6 +1590,43 @@
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
"mark_all_folders_read": "Mark all folders as read",
"new_subfolder": "New subfolder...",
"new_folder": "New folder...",
"rename": "Rename...",
"empty_folder": "Empty folder",
"empty_folder_generic": "Empty folder",
"delete_folder": "Delete folder",
"refresh": "Refresh",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
"delete_confirm_message": "Permanently delete the folder \"{name}\"? This action cannot be undone.",
"prompt_new_subfolder": "Enter a name for the new subfolder.",
"prompt_new_folder": "Enter a name for the new folder.",
"prompt_rename": "Enter a new name for this folder.",
"toast_marked_read": "Folder marked as read",
"toast_marked_read_count": "Marked {count, plural, one {1 message} other {# messages}} as read",
"toast_already_read": "No unread messages",
"toast_marked_all_read": "All folders marked as read",
"toast_emptied": "Folder emptied",
"toast_folder_created": "Folder created",
"toast_folder_renamed": "Folder renamed",
"toast_folder_deleted": "Folder deleted",
"toast_error_mark_read": "Failed to mark as read",
"toast_error_empty": "Failed to empty folder",
"toast_error_create": "Failed to create folder",
"toast_error_rename": "Failed to rename folder",
"toast_error_delete": "Failed to delete folder",
"toast_error_delete_has_children": "Folder has subfolders. Remove them first.",
"toast_error_delete_has_email": "Folder is not empty. Empty it first.",
"placeholder_folder_name": "Folder name",
"create": "Create",
"rename_confirm": "Rename"
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
@@ -1737,6 +1788,8 @@
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
"empty_search": "Nessun contatto corrisponde alla ricerca",
"empty_search_hint": "Prova con un altro termine di ricerca",
"empty_filtered": "Nessun contatto corrisponde ai filtri",
"empty_filtered_hint": "Modifica o cancella i filtri",
"clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard",
"delete_confirm_title": "Elimina contatto",
@@ -1762,7 +1815,13 @@
"renamed": "Rubrica rinominata",
"rename_failed": "Impossibile rinominare la rubrica",
"default": "Predefinita",
"manage": "Gestisci rubriche"
"manage": "Gestisci rubriche",
"share": "Condividi rubrica",
"new_contact_in_book": "Nuovo contatto in questa rubrica",
"delete": "Elimina rubrica",
"confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.",
"deleted": "Rubrica eliminata",
"delete_failed": "Impossibile eliminare la rubrica"
},
"detail": {
"emails": "Indirizzi email",
@@ -1814,7 +1873,22 @@
"import_to_smime": "Importa in S/MIME",
"cert_already_imported": "Certificato già importato",
"cert_imported": "Certificato importato",
"cert_import_failed": "Importazione del certificato non riuscita"
"cert_import_failed": "Importazione del certificato non riuscita",
"section_contact": "Contact details",
"section_work": "Work",
"section_personal": "Personal",
"email_default_label": "Email",
"phone_default_label": "Phone",
"address_default_label": "Address",
"online_service_default_label": "Online",
"organization_label": "Organization",
"title_label": "Title",
"role_label": "Role",
"language_label": "Language",
"related_default_label": "Related",
"more_actions": "Altre azioni",
"age_years": "{count, plural, one {1 anno} other {# anni}}",
"years_since": "{count, plural, one {1 anno fa} other {# anni fa}}"
},
"activity": {
"recent_emails": "Recent Emails",
@@ -1823,7 +1897,9 @@
"no_events": "No upcoming events",
"no_subject": "(No subject)",
"no_title": "(No title)",
"load_failed": "Failed to load"
"load_failed": "Failed to load",
"unknown_sender": "Unknown sender",
"all_day": "All day"
},
"form": {
"create_title": "Nuovo contatto",
@@ -1919,7 +1995,13 @@
"email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto",
"delete": "Elimina"
"delete": "Elimina",
"upload_photo": "Carica foto",
"remove_photo": "Rimuovi foto",
"photo_hint": "JPG o PNG, fino a 10 MB. Verrà ridimensionata.",
"photo_too_large": "L'immagine è troppo grande (max 10 MB)",
"photo_invalid": "File immagine non valido",
"change_photo": "Cambia"
},
"groups": {
"create": "Nuovo gruppo",
@@ -1979,6 +2061,37 @@
"error_create": "Impossibile creare il contatto",
"error_update": "Impossibile aggiornare il contatto",
"error_delete": "Impossibile eliminare il contatto"
},
"context_menu": {
"open": "Apri",
"edit": "Modifica",
"send_email": "Invia email",
"add_to_group": "Aggiungi al gruppo",
"export_vcard": "Esporta come vCard",
"delete": "Elimina",
"call": "Chiama",
"duplicate": "Duplica",
"print": "Stampa"
},
"filters": {
"toggle": "Filtri",
"select": "Seleziona",
"clear": "Azzera",
"close": "Chiudi",
"title": "Filtri avanzati",
"organization": "Azienda",
"organization_placeholder": "es. Acme",
"job_title": "Ruolo",
"job_title_placeholder": "es. Designer",
"location": "Luogo",
"location_placeholder": "Città o paese",
"email_domain": "Dominio email",
"email_domain_placeholder": "esempio.com",
"birthday_month": "Compleanno in",
"any_month": "Qualsiasi mese",
"has_email": "Con email",
"has_phone": "Con telefono",
"has_photo": "Con foto"
}
},
"calendar": {
@@ -2054,6 +2167,7 @@
"tentative": "Provvisorio",
"needs_action": "In attesa di risposta",
"remove": "Rimuovi",
"edit": "Modifica",
"email_placeholder": "Aggiungi indirizzo email o cerca contatti",
"send_invitations": "Invia inviti ai partecipanti",
"status_summary": "{accepted} accettato/i, {pending} in attesa",
@@ -2232,7 +2346,9 @@
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
"clear_events": "Cancella eventi",
"events_cleared": "{count} eventi cancellati",
"error_clear": "Impossibile cancellare gli eventi del calendario"
"error_clear": "Impossibile cancellare gli eventi del calendario",
"share": "Condividi calendario",
"new_event_in_calendar": "Nuovo evento in questo calendario"
},
"subscription": {
"title": "Abbonamento iCal",
@@ -2598,5 +2714,28 @@
},
"unified_mailbox": {
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
},
"sharing": {
"title": "Condividi \"{name}\"",
"description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.",
"no_shares": "Non ancora condiviso.",
"add_person": "Aggiungi persona o gruppo",
"search_placeholder": "Cerca per nome o email…",
"loading_principals": "Caricamento utenti…",
"no_principals": "Nessun altro utente o gruppo trovato.",
"no_match": "Nessun risultato.",
"remove": "Rimuovi accesso",
"group": "Gruppo",
"share_added": "Accesso concesso",
"share_updated": "Accesso aggiornato",
"share_removed": "Accesso rimosso",
"share_failed": "Impossibile aggiornare la condivisione",
"preset": {
"freeBusy": "Solo libero/occupato",
"read": "Sola lettura",
"readWrite": "Lettura e scrittura",
"manager": "Gestore",
"custom": "Personalizzato"
}
}
}

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