Merge branch 'dev' into main
@@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Set to "false" to disable if using a non-Stalwart JMAP server.
|
||||
# STALWART_FEATURES=true
|
||||
|
||||
# If your reverse proxy doesn't forward Stalwart management API paths
|
||||
# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's
|
||||
# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set.
|
||||
# STALWART_API_URL=https://admin.example.com
|
||||
|
||||
# =============================================================================
|
||||
# OAuth / OpenID Connect (optional)
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.0 (2026-04-22)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session.
|
||||
|
||||
### Features
|
||||
|
||||
- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16
|
||||
- **Admin**: Add API Keys management and IP allowlist for App Passwords
|
||||
- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions
|
||||
- **Contacts**: Add contact activity component showing recent emails and upcoming events
|
||||
- **Contacts**: Add right-click context menu
|
||||
- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings
|
||||
- **Calendar**: Support resizing events from the top edge
|
||||
- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change
|
||||
- **Calendar**: Optimize layout of overlapping events
|
||||
- **Calendar**: Add collapsible details to calendar invitation banner
|
||||
- **Email**: Implement batch archiving and bulk moving of emails
|
||||
- **Email**: Show full folder path in move/drop toast
|
||||
- **Settings**: Reorganize settings into 6 groups with clearer tabs
|
||||
- **Navigation**: Add account-addition button to the navigation rail
|
||||
- **Mobile**: Streamline email viewer header layout
|
||||
- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217)
|
||||
- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data
|
||||
- **Mailbox**: Improve error message on mailbox creation failure
|
||||
- **Auth**: Skip `checkAuth` on route change when already authenticated
|
||||
- **Auth**: Clean up unused imports and improve TOTP QR code rendering
|
||||
- **UI**: Align hover styles and selection-toggle target with focused item
|
||||
- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker
|
||||
|
||||
### Refactor
|
||||
|
||||
- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP)
|
||||
|
||||
### Chore
|
||||
|
||||
- **i18n**: Add missing translation keys
|
||||
- **Deps**: Bump dependencies to latest compatible versions
|
||||
|
||||
## 1.4.14 (2026-04-16)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Features
|
||||
|
||||
## Mail
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Draft auto-save with identity preservation
|
||||
- Attachment upload, download, and inline preview; forgotten-attachment warning
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||
- Star/unstar with configurable mark-as-read delay
|
||||
- Virtual scrolling for large mailboxes
|
||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||
- Plain-text composer mode and Reply-To support
|
||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||
- Folder management with icon picker, subfolders, and sidebar counts
|
||||
- Print directly from the viewer
|
||||
- Browser history sync for back/forward navigation
|
||||
|
||||
## Calendar
|
||||
|
||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||
- iCalendar import with preview, bulk create, and UID deduplication
|
||||
- iCal / webcal subscriptions with editing and batch import
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
|
||||
## Contacts
|
||||
|
||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||
- Multiple address books with drag-and-drop between books
|
||||
- Contact groups with member management
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Trusted senders stored in a dedicated JMAP address book
|
||||
- Autocomplete in the composer (To / Cc / Bcc)
|
||||
|
||||
## Filters & Templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
- Previews for images, text, audio, and video
|
||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||
- Newsletter unsubscribe (RFC 2369)
|
||||
|
||||
## Interface
|
||||
|
||||
- Three-pane layout with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
- Interactive guided tour for new users
|
||||
- Right-click context menus, toast notifications with undo
|
||||
- Customizable toolbar position, favicon, and login branding
|
||||
- Pinnable sidebar apps with drag-and-drop reordering
|
||||
- Encrypted settings sync across devices
|
||||
- Storage quota display
|
||||
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||
|
||||
## Internationalization
|
||||
|
||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
## Identity & Multi-Account
|
||||
|
||||
- Up to 5 simultaneous accounts with instant switching and per-account session persistence
|
||||
- Account switcher with connection status and default account selection
|
||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions
|
||||
- Shared folders across accounts
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
## Admin & Extensibility
|
||||
|
||||
- Stalwart admin dashboard with dedicated policy sections
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement
|
||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||
- Bundled plugins including Jitsi Meet calendar integration
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, and dynamic manifest
|
||||
- Automatic update check with server-side logging of new releases
|
||||
- Structured logging (`text` or `json`) with category-based levels
|
||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||
- Demo mode with fixture data – no mail server required
|
||||
@@ -8,12 +8,11 @@
|
||||
|
||||
# Bulwark Webmail
|
||||
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).<br/>
|
||||
Built with Next.js and the JMAP protocol.
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/mail-dark.png" />
|
||||
<img src="screenshots/mail-white.png" alt="Mail view" width="100%" />
|
||||
</picture>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
|
||||
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||
|
||||
**Calendar** - Month, week, day, and agenda views with event management
|
||||
|
||||
</td>
|
||||
<td width="50%"><img src="screenshots/calendar.png" alt="Calendar" /></td>
|
||||
<td width="50%"><img src="screenshots/contacts.png" alt="Contacts" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||
|
||||
**Contacts** - Contact management with groups and vCard support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||
|
||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||
|
||||
</td>
|
||||
<td><sub><b>Calendar</b> – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.</sub></td>
|
||||
<td><sub><b>Contacts</b> – multiple address books, groups, vCard import/export, and autocomplete in the composer.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/theme.png" alt="Themes" /></td>
|
||||
<td><img src="screenshots/plugins.png" alt="Plugins" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Themes</b> – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.</sub></td>
|
||||
<td><sub><b>Plugins</b> – extend the client with bundled or third-party plugins installed from a .zip file.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/mail-white.png" alt="Light mode" /></td>
|
||||
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>More screenshots</summary>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
## Overview
|
||||
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||
Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
|
||||
**Light mode** - Full theme support with intelligent color transformation
|
||||
- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a Stalwart admin dashboard.
|
||||
|
||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||
|
||||
**Settings** - Appearance, identities, filters, templates, and more
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||
|
||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
### Mail
|
||||
|
||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding)
|
||||
- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle
|
||||
- **Unified mailbox** - view emails across all accounts in a single list
|
||||
- **Draft auto-save** with discard confirmation; full draft editing with identity preservation
|
||||
- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten
|
||||
- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions
|
||||
- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag
|
||||
- **Archive modes** - archive directly or organize archived mail by year or month
|
||||
- **Print** emails directly from the viewer
|
||||
- **Answered/forwarded status icons** in email list and thread views
|
||||
- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment
|
||||
- **Star/unstar** with configurable mark-as-read delay
|
||||
- **Virtual scrolling** for large mailboxes
|
||||
- **Quick reply** from the viewer
|
||||
- **Hover actions** - configurable quick-action buttons on email rows with customizable placement
|
||||
- **Sender avatars** - favicon-based with negative caching for performance
|
||||
- **Recipient popover** for quick contact interaction
|
||||
- **Plain text composer mode** and auto-select reply identity
|
||||
- **Reply-to addresses** support in the composer
|
||||
- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
- **message/rfc822 unwrapping** for embedded messages
|
||||
- **Folder management** - create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** - unread and total counts displayed in sidebar
|
||||
- **Browser history sync** - back/forward navigation mirrors mail view state
|
||||
|
||||
### Calendar
|
||||
|
||||
- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view
|
||||
- **Event hover preview** popover with configurable details
|
||||
- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap)
|
||||
- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion
|
||||
- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment
|
||||
- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import
|
||||
- **iCalendar import** with preview, bulk create, and UID deduplication
|
||||
- **iCal / webcal subscriptions** with editing and batch import
|
||||
- **Birthday calendar** - auto-generated from contacts
|
||||
- **Virtual locations** - video conference URLs as first-class event fields
|
||||
- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird)
|
||||
- **Shared calendars** with visual grouping in the sidebar
|
||||
- **CalDAV discovery** with automatic calendar home resolution for multi-account setups
|
||||
- **Week numbers** in mini-calendar sidebar
|
||||
- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback
|
||||
- **Real-time sync** via JMAP push
|
||||
|
||||
### Contacts
|
||||
|
||||
- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback
|
||||
- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form
|
||||
- **Collapsible sidebar** with address book grouping and bulk operations
|
||||
- **Contact groups** with group expansion and member management
|
||||
- **vCard import/export** (RFC 6350) with duplicate detection
|
||||
- **Trusted senders** stored in a dedicated JMAP address book
|
||||
- **Autocomplete** in composer (To/Cc/Bcc)
|
||||
- **Bulk operations** - multi-select, delete, group add, export
|
||||
|
||||
### Filters & Automation
|
||||
|
||||
- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661)
|
||||
- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view
|
||||
- **External rule preservation** - rules authored in other clients are displayed and preserved
|
||||
- **Raw Sieve editor** with syntax validation
|
||||
- **Vacation responder** with date range scheduling and sidebar indicator
|
||||
- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.)
|
||||
|
||||
### Files
|
||||
|
||||
- **File browser** with JMAP FileNode cloud storage (Stalwart native)
|
||||
- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering)
|
||||
- **Dynamic upload limits** - respects the server-configured maximum upload size
|
||||
- **Folder navigation** with breadcrumb path and tree sidebar
|
||||
- **Grid and list views** with sorting by name, size, or date
|
||||
- **Clipboard operations** - cut, copy, paste, duplicate files
|
||||
- **File preview** for images, text, audio, video, and more
|
||||
- **Favorites and recent files** for quick access
|
||||
- **Bulk operations** - multi-select, delete, move, download
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
- **External content blocked** by default - trusted senders list for auto-load
|
||||
- **HTML sanitization** via DOMPurify with XSS prevention
|
||||
- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation
|
||||
- **SPF/DKIM/DMARC** status indicators
|
||||
- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments
|
||||
- **TOTP two-factor authentication**
|
||||
- **Account security panel** - manage passwords and 2FA via Stalwart admin API
|
||||
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
|
||||
- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
|
||||
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
|
||||
- **Newsletter unsubscribe** (RFC 2369)
|
||||
|
||||
### Interface
|
||||
|
||||
- **Three-pane layout** - sidebar, email list, viewer with resizable columns
|
||||
- **Dark and light themes** with intelligent email color transformation
|
||||
- **Always-light email rendering** option for problematic HTML messages in dark theme
|
||||
- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support
|
||||
- **Keyboard shortcuts** - full navigation without a mouse
|
||||
- **Drag-and-drop** email organization between mailboxes and tag assignment
|
||||
- **Interactive guided tour** - onboarding walkthrough for new users
|
||||
- **Right-click context menus**, toast notifications with undo, form validation with shake feedback
|
||||
- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding
|
||||
- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes
|
||||
- **Settings sync** - preferences synchronized with the server (encrypted)
|
||||
- **Storage quota** display
|
||||
- **Version badge** in settings
|
||||
- **Focused mode** with proper viewport bounds
|
||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
|
||||
### Internationalization
|
||||
|
||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
### Identity Management
|
||||
|
||||
- **Multiple sender identities** with per-identity signatures
|
||||
- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes
|
||||
- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions
|
||||
- **Identity badges** in viewer and email list
|
||||
|
||||
### Multi-Account
|
||||
|
||||
- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence
|
||||
- **Account switcher** with connection status, default account selection, and per-account logout
|
||||
- **Per-account settings** - encrypted settings storage with server-side sync
|
||||
- **Shared folders** across accounts
|
||||
- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
|
||||
### Admin & Extensibility
|
||||
|
||||
- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections
|
||||
- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement
|
||||
- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles
|
||||
- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`)
|
||||
- **Bundled plugins** - Jitsi Meet calendar integration
|
||||
|
||||
### Operations
|
||||
|
||||
- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors)
|
||||
- **Automatic update check** - server logs when a newer release is available
|
||||
- **Logging categories** with `text` or `json` formats for log aggregation
|
||||
- **Docker images** - release (`main`) and development (`dev`) channels on GHCR
|
||||
- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
@@ -255,7 +82,7 @@ Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git
|
||||
cd webmail
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local - set JMAP_SERVER_URL
|
||||
# Edit .env.local – set JMAP_SERVER_URL
|
||||
npm run build && npm start
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (mock JMAP server included)
|
||||
npm run typecheck # Type checking
|
||||
npm run lint # Linting
|
||||
npm run dev # Dev server with a mock JMAP server
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `.env.local`:
|
||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||
|
||||
```env
|
||||
# Required
|
||||
@@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||
|
||||
<details>
|
||||
<summary>Server Listen Address</summary>
|
||||
<summary>Server listen address</summary>
|
||||
|
||||
```env
|
||||
HOSTNAME=0.0.0.0 # Default; use "::" for IPv6
|
||||
PORT=3000 # Default listen port
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OAuth2/OIDC (SSO)</summary>
|
||||
<summary>OAuth2 / OIDC</summary>
|
||||
|
||||
```env
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
|
||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||
```
|
||||
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||
@@ -318,29 +143,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Remember Me & Settings Sync</summary>
|
||||
<summary>Session & settings sync</summary>
|
||||
|
||||
```env
|
||||
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret
|
||||
SESSION_SECRET= # openssl rand -base64 32
|
||||
SESSION_SECRET_FILE=/session-secret # path to a file containing the secret
|
||||
|
||||
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
|
||||
SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker)
|
||||
SETTINGS_SYNC_ENABLED=true
|
||||
SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker
|
||||
```
|
||||
|
||||
Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry).
|
||||
Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom JMAP Endpoint</summary>
|
||||
<summary>Custom JMAP endpoint</summary>
|
||||
|
||||
```env
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||
```
|
||||
|
||||
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
|
||||
Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all
|
||||
|
||||
```env
|
||||
APP_NAME=My Webmail
|
||||
APP_SHORT_NAME=Webmail # Home-screen label on mobile
|
||||
APP_DESCRIPTION=Your personal mail # Shown during PWA install
|
||||
APP_SHORT_NAME=Webmail
|
||||
APP_DESCRIPTION=Your personal mail
|
||||
|
||||
FAVICON_URL=/branding/favicon.svg
|
||||
PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6 # Browser chrome color
|
||||
PWA_BACKGROUND_COLOR=#ffffff # PWA splash background
|
||||
PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL
|
||||
PWA_THEME_COLOR=#3b82f6
|
||||
PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||
@@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extension Directory</summary>
|
||||
<summary>Extension directory</summary>
|
||||
|
||||
```env
|
||||
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
@@ -382,14 +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_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/*
|
||||
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>
|
||||
@@ -424,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||
|
||||
## Why Stalwart?
|
||||
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies.
|
||||
[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL v3](LICENSE)
|
||||
|
||||
This repository also preserves the original MIT attribution notice for the
|
||||
fork lineage in [NOTICE](NOTICE).
|
||||
[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon.
|
||||
Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon.
|
||||
|
||||
@@ -126,8 +126,15 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [events, detailEvent]);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
|
||||
@@ -98,13 +98,20 @@ export default function ContactsPage() {
|
||||
|
||||
// Panel resize state - contact list
|
||||
const [listWidth, setListWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; }
|
||||
try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; }
|
||||
});
|
||||
const [isListResizing, setIsListResizing] = useState(false);
|
||||
const listDragStartWidth = useRef(320);
|
||||
const listDragStartWidth = useRef(384);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
@@ -172,21 +179,6 @@ export default function ContactsPage() {
|
||||
return getGroupMembers(activeCategory.groupId);
|
||||
}, [activeCategory, individuals, getGroupMembers]);
|
||||
|
||||
// Label for the current category
|
||||
const categoryLabel = useMemo(() => {
|
||||
if (activeCategory === "all") return t("tabs.all");
|
||||
if (activeCategory === "uncategorized") return t("no_category");
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||
return book?.name || t("tabs.all");
|
||||
}
|
||||
if ("keyword" in activeCategory) {
|
||||
return activeCategory.keyword;
|
||||
}
|
||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||
}, [activeCategory, contacts, addressBooks, t]);
|
||||
|
||||
const handleSelectCategory = useCallback((category: ContactCategory) => {
|
||||
setActiveCategory(category);
|
||||
clearSelection();
|
||||
@@ -259,9 +251,7 @@ export default function ContactsPage() {
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
|
||||
const deleteContactById = useCallback(async (contactId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm"),
|
||||
@@ -272,18 +262,60 @@ export default function ContactsPage() {
|
||||
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
await deleteContact(client, selectedContact.id);
|
||||
await deleteContact(client, contactId);
|
||||
} else {
|
||||
deleteLocalContact(selectedContact.id);
|
||||
deleteLocalContact(contactId);
|
||||
}
|
||||
toast.success(t("toast.deleted"));
|
||||
setView("list");
|
||||
if (selectedContactId === contactId) setView("list");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete contact:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
await deleteContactById(selectedContact.id);
|
||||
};
|
||||
|
||||
const handleEditContact = useCallback((id: string) => {
|
||||
setSelectedContact(id);
|
||||
setView("edit");
|
||||
}, [setSelectedContact]);
|
||||
|
||||
const handleDeleteContact = useCallback((contact: ContactCard) => {
|
||||
void deleteContactById(contact.id);
|
||||
}, [deleteContactById]);
|
||||
|
||||
const handleAddContactToGroup = useCallback((id: string) => {
|
||||
clearSelection();
|
||||
toggleContactSelection(id);
|
||||
if (groups.length === 0) {
|
||||
setView("group-create");
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -585,6 +617,16 @@ export default function ContactsPage() {
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddToGroup={
|
||||
selectedContact
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={
|
||||
selectedContact
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
@@ -680,7 +722,6 @@ export default function ContactsPage() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
categoryLabel={categoryLabel}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
@@ -690,6 +731,9 @@ export default function ContactsPage() {
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkExport={handleBulkExport}
|
||||
onEditContact={handleEditContact}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -701,7 +745,7 @@ export default function ContactsPage() {
|
||||
setIsListResizing(false);
|
||||
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
|
||||
onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -105,8 +105,15 @@ export default function FilesPage() {
|
||||
|
||||
const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null;
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
@@ -455,8 +455,15 @@ export default function Home() {
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
@@ -493,7 +500,10 @@ export default function Home() {
|
||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||
const loadData = async () => {
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const loadData = async (attempt = 1) => {
|
||||
try {
|
||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
||||
await Promise.all([
|
||||
@@ -505,6 +515,15 @@ export default function Home() {
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
// On first login the server may still be provisioning mailboxes.
|
||||
// Retry a few times with back-off before giving up.
|
||||
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
|
||||
const delay = Math.min(1000 * attempt, 5000);
|
||||
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
|
||||
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch emails for the selected mailbox
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
@@ -538,6 +557,12 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
client.closePushNotifications();
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup push notifications on unmount
|
||||
@@ -813,8 +838,11 @@ export default function Home() {
|
||||
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToArchive) return;
|
||||
|
||||
// Find archive mailbox
|
||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
||||
// and each iteration needs to see folders created by prior iterations.
|
||||
const currentMailboxes = useEmailStore.getState().mailboxes;
|
||||
|
||||
const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
if (!archiveMailbox) return;
|
||||
|
||||
const { archiveMode } = useSettingsStore.getState();
|
||||
@@ -823,14 +851,12 @@ export default function Home() {
|
||||
if (archiveMode === 'single') {
|
||||
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||
} else {
|
||||
// Determine year/month from the email's received date
|
||||
const emailDate = new Date(emailToArchive.receivedAt);
|
||||
const year = emailDate.getFullYear().toString();
|
||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||
|
||||
// Find or create year subfolder under archive
|
||||
let yearMailbox = mailboxes.find(
|
||||
let yearMailbox = currentMailboxes.find(
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
if (!yearMailbox) {
|
||||
@@ -841,9 +867,9 @@ export default function Home() {
|
||||
if (archiveMode === 'year') {
|
||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||
} else {
|
||||
// archiveMode === 'month' - find or create month subfolder under year
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = mailboxes.find(
|
||||
const afterYear = useEmailStore.getState().mailboxes;
|
||||
let monthMailbox = afterYear.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
);
|
||||
if (!monthMailbox) {
|
||||
@@ -1492,9 +1518,12 @@ export default function Home() {
|
||||
} else {
|
||||
selectAllEmails();
|
||||
}
|
||||
} else {
|
||||
// Enter selection mode by selecting the first email
|
||||
if (emails.length > 0) toggleEmailSelection(emails[0].id);
|
||||
} else if (emails.length > 0) {
|
||||
const currentId = selectedEmail?.id;
|
||||
const target = currentId && emails.some((e) => e.id === currentId)
|
||||
? currentId
|
||||
: emails[0].id;
|
||||
toggleEmailSelection(target);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -1898,14 +1927,6 @@ export default function Home() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile Header for Viewer */}
|
||||
{isMobile && activeView === "viewer" && (
|
||||
<MobileViewerHeader
|
||||
subject={selectedEmail?.subject}
|
||||
onBack={handleMobileBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
LogOut,
|
||||
Settings as SettingsIcon,
|
||||
Palette,
|
||||
Mail,
|
||||
User,
|
||||
Shield,
|
||||
UserPen,
|
||||
@@ -20,17 +19,27 @@ import {
|
||||
FolderOpen,
|
||||
Tags,
|
||||
HardDrive,
|
||||
Wrench,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
LayoutGrid,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
EyeOff,
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { EmailSettings } from '@/components/settings/email-settings';
|
||||
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
@@ -39,7 +48,8 @@ import { CalendarManagementSettings } from '@/components/settings/calendar-manag
|
||||
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { AboutDataSettings } from '@/components/settings/about-data-settings';
|
||||
import { DebugSettings } from '@/components/settings/debug-settings';
|
||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
@@ -62,8 +72,33 @@ import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced';
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
type Tab =
|
||||
| 'account'
|
||||
| 'language'
|
||||
| 'notifications'
|
||||
| 'appearance'
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
| 'templates'
|
||||
| 'folders'
|
||||
| 'keywords'
|
||||
| 'security'
|
||||
| 'encryption'
|
||||
| 'content_senders'
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
| 'files'
|
||||
| 'sidebar_apps'
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
@@ -74,28 +109,54 @@ interface TabDef {
|
||||
}
|
||||
|
||||
const tabIcons: Record<Tab, LucideIcon> = {
|
||||
appearance: Palette,
|
||||
email: Mail,
|
||||
notifications: Bell,
|
||||
account: User,
|
||||
security: Shield,
|
||||
language: Languages,
|
||||
notifications: Bell,
|
||||
appearance: Palette,
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
identities: UserPen,
|
||||
encryption: KeyRound,
|
||||
vacation: PalmtreeIcon,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
security: Shield,
|
||||
encryption: KeyRound,
|
||||
content_senders: EyeOff,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
plugins: Puzzle,
|
||||
advanced: Wrench,
|
||||
debug: Bug,
|
||||
};
|
||||
|
||||
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
||||
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||
|
||||
// Map legacy tab IDs to current ones; runs once on read of localStorage.
|
||||
const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
email: 'reading',
|
||||
advanced: 'about_data',
|
||||
};
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
try {
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (!saved) return 'appearance';
|
||||
if (saved in LEGACY_TAB_MAP) {
|
||||
const migrated = LEGACY_TAB_MAP[saved];
|
||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||
return migrated;
|
||||
}
|
||||
return saved as Tab;
|
||||
} catch {
|
||||
return 'appearance';
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
@@ -107,13 +168,7 @@ export default function SettingsPage() {
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (saved) return saved as Tab;
|
||||
} catch { /* ignore */ }
|
||||
return 'appearance';
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
@@ -124,21 +179,28 @@ export default function SettingsPage() {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(256);
|
||||
|
||||
// Check auth on mount
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
useEffect(() => {
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.client) {
|
||||
setInitialCheckDone(true);
|
||||
return;
|
||||
}
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Listen for tab change events from child components
|
||||
// Listen for tab change events from child components (with legacy migration)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const tab = (e as CustomEvent).detail as Tab;
|
||||
if (tab) {
|
||||
setActiveTab(tab);
|
||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||
}
|
||||
const raw = (e as CustomEvent).detail as string;
|
||||
if (!raw) return;
|
||||
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
|
||||
setActiveTab(tab);
|
||||
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
|
||||
};
|
||||
window.addEventListener('settings-tab-change', handler);
|
||||
return () => window.removeEventListener('settings-tab-change', handler);
|
||||
@@ -161,25 +223,41 @@ export default function SettingsPage() {
|
||||
const supportsFiles = client?.supportsFiles() ?? false;
|
||||
|
||||
const tabs: TabDef[] = [
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
|
||||
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
|
||||
// General
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []),
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []),
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
|
||||
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||
|
||||
// Privacy & Security
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []),
|
||||
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
|
||||
// Group tabs by category
|
||||
@@ -191,6 +269,10 @@ export default function SettingsPage() {
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
// If active tab is not in the visible list (e.g., feature disabled), fall back.
|
||||
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
|
||||
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
|
||||
|
||||
const handleTabSelect = (tabId: Tab) => {
|
||||
setActiveTab(tabId);
|
||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||
@@ -199,39 +281,42 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? '';
|
||||
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
|
||||
|
||||
const renderTabContent = () => (
|
||||
<>
|
||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||
{activeTab === 'email' && <EmailSettings />}
|
||||
{activeTab === 'notifications' && <NotificationSettings />}
|
||||
{activeTab === 'account' && <AccountSettings />}
|
||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
||||
{activeTab === 'identities' && <IdentitySettings />}
|
||||
{activeTab === 'encryption' && <SmimeSettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{activeTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
{activeTab === 'keywords' && <KeywordSettings />}
|
||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{activeTab === 'themes' && <ThemesSettings />}
|
||||
{activeTab === 'plugins' && <PluginsSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
{effectiveActiveTab === 'account' && <AccountSettings />}
|
||||
{effectiveActiveTab === 'language' && <LanguageSettings />}
|
||||
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
|
||||
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (!isDesktop) {
|
||||
// Mobile: show content view
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
{/* Mobile content header */}
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -244,14 +329,12 @@ export default function SettingsPage() {
|
||||
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -264,10 +347,8 @@ export default function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: show tab list
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -283,7 +364,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="py-2">
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
@@ -319,7 +399,6 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={logout}
|
||||
@@ -331,7 +410,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -347,7 +425,6 @@ export default function SettingsPage() {
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="flex h-dvh bg-background">
|
||||
{/* Navigation Rail */}
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -366,7 +443,6 @@ export default function SettingsPage() {
|
||||
)}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
{/* Settings Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary flex flex-col",
|
||||
@@ -374,7 +450,6 @@ export default function SettingsPage() {
|
||||
)}
|
||||
style={{ width: `${settingsSidebarWidth}px` }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -387,7 +462,6 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
@@ -406,14 +480,14 @@ export default function SettingsPage() {
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
activeTab === tab.id
|
||||
effectiveActiveTab === tab.id
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{tab.label}
|
||||
{tab.experimental && (
|
||||
@@ -430,7 +504,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar resize handle */}
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
@@ -441,10 +514,8 @@ export default function SettingsPage() {
|
||||
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
|
||||
/>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||
@@ -452,7 +523,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Tab Content */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,6 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/auth
|
||||
* Proxy to Stalwart GET /api/account/auth
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart auth info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: detail || 'Failed to fetch auth info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/auth
|
||||
* Proxy to Stalwart POST /api/account/auth
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart auth update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart GET /api/account/crypto
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart crypto info failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: detail || 'Failed to fetch crypto info' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/crypto
|
||||
* Proxy to Stalwart POST /api/account/crypto
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/crypto`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart crypto update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/jmap
|
||||
*
|
||||
* Passthrough to Stalwart's JMAP endpoint using the stored basic-auth
|
||||
* context so the browser does not need access to the user's credentials.
|
||||
*
|
||||
* Body: standard JMAP request `{ using: string[], methodCalls: [...] }`
|
||||
*
|
||||
* In Stalwart 0.16 all management operations (password change, app
|
||||
* passwords, API keys, account settings, etc.) are exposed as JMAP
|
||||
* methods under the `x:` namespace on the same endpoint.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
|
||||
const response = await fetch(`${creds.serverUrl}/jmap/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
return new NextResponse(responseText, {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Stalwart JMAP passthrough error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/account/stalwart/password
|
||||
* Change user password via Stalwart PATCH /api/principal/{name}
|
||||
*
|
||||
* Body: { currentPassword: string, newPassword: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = await request.json();
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify current password by attempting to authenticate
|
||||
const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`;
|
||||
const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': verifyAuth },
|
||||
});
|
||||
|
||||
if (!verifyResponse.ok) {
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Change password via Stalwart principal API
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{ action: 'set', field: 'secrets', value: newPassword },
|
||||
]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('Stalwart password change failed', { status: response.status });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to change password', details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// If session cookie exists, update it with the new password
|
||||
const cookieStore = await cookies();
|
||||
|
||||
if (creds.hasSessionCookie) {
|
||||
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
|
||||
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
if (creds.authHeader.startsWith('Basic ')) {
|
||||
setStalwartAuthContextInStore(cookieStore, creds.slot, {
|
||||
serverUrl: creds.serverUrl,
|
||||
username: creds.username,
|
||||
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Parse Stalwart error response to extract meaningful error message
|
||||
*/
|
||||
function parseStalwartError(responseText: string): string {
|
||||
try {
|
||||
const error = JSON.parse(responseText);
|
||||
if (error.detail) return error.detail;
|
||||
if (error.error) return error.error;
|
||||
return `HTTP ${error.status || 'Error'}`;
|
||||
} catch {
|
||||
return responseText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/principal
|
||||
* Proxy to Stalwart GET /api/principal/{username}
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const detail = parseStalwartError(text);
|
||||
logger.warn('Stalwart principal fetch failed', { status: response.status, detail });
|
||||
return NextResponse.json(
|
||||
{ error: detail || 'Failed to fetch principal' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/account/stalwart/principal
|
||||
* Proxy to Stalwart PATCH /api/principal/{username}
|
||||
* Body: PrincipalUpdateAction[] (array of {action, field, value})
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Prevent secrets field from being changed through this endpoint (use /password instead)
|
||||
if (Array.isArray(body)) {
|
||||
const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets');
|
||||
if (hasSecrets) {
|
||||
return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': creds.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Stalwart principal update failed', { status: response.status });
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/account/stalwart/probe
|
||||
* Detect whether the JMAP server is Stalwart by probing /api/account/auth
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${creds.apiUrl}/api/account/auth`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean';
|
||||
|
||||
return NextResponse.json({ isStalwart });
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ isStalwart: false });
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,38 @@ import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
|
||||
* Permissions that indicate Stalwart admin privileges.
|
||||
* If the authenticated user has at least one of these, they can manage
|
||||
* system-level resources and are considered an admin.
|
||||
*/
|
||||
const ADMIN_PERMISSIONS = [
|
||||
'sysAccountQuery',
|
||||
'sysTenantQuery',
|
||||
'sysSystemSettingsGet',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the current user is a Stalwart admin by inspecting the
|
||||
* permissions list returned by Stalwart's /api/account endpoint.
|
||||
*/
|
||||
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) return false;
|
||||
|
||||
// Probe admin-only endpoint: listing principals requires admin privileges
|
||||
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
|
||||
const response = await fetch(`${creds.serverUrl}/api/account`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
const isAdmin = response.ok;
|
||||
if (!response.ok) {
|
||||
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json() as { permissions?: string[] };
|
||||
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
|
||||
const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p));
|
||||
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
|
||||
return isAdmin;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
|
||||
/**
|
||||
* GET /api/admin/stalwart-check
|
||||
* Check if the currently logged-in user is a Stalwart admin.
|
||||
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
|
||||
// Probe an admin-only endpoint: listing principals requires admin privileges.
|
||||
// Use limit=1 to minimize payload.
|
||||
const url = `${creds.apiUrl}/api/principal?limit=1`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': creds.authHeader },
|
||||
});
|
||||
|
||||
const isStalwartAdmin = response.ok;
|
||||
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
|
||||
|
||||
return NextResponse.json({ isStalwartAdmin }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Stalwart admin check error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
});
|
||||
return NextResponse.json({ isStalwartAdmin: false }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>' },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const davPath = request.headers.get('X-WebDAV-Path') || '/';
|
||||
const baseUrl = creds.apiUrl.replace(/\/$/, '');
|
||||
const baseUrl = creds.serverUrl.replace(/\/$/, '');
|
||||
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
|
||||
|
||||
// Build headers for the upstream request
|
||||
|
||||
@@ -119,6 +119,7 @@ export function CalendarDayView({
|
||||
created: t("notifications.event_created"),
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
isMobile,
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
@@ -246,9 +247,11 @@ export function CalendarDayView({
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const isResizing = resizeVisual?.eventId === ev.id;
|
||||
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
@@ -270,11 +273,21 @@ export function CalendarDayView({
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
|
||||
@@ -183,6 +183,7 @@ export function CalendarWeekView({
|
||||
created: t("notifications.event_created"),
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
isMobile,
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
@@ -384,9 +385,11 @@ export function CalendarWeekView({
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const isResizing = resizeVisual?.eventId === ev.id;
|
||||
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
@@ -408,11 +411,21 @@ export function CalendarWeekView({
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
|
||||
@@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
}, 200);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}, [query, addParticipant]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
|
||||
key={`${p.email}-${i}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-muted text-foreground max-w-[200px]"
|
||||
>
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRemove(p.email);
|
||||
setQuery(p.email);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
|
||||
aria-label={`${t("edit")} ${p.name || p.email}`}
|
||||
>
|
||||
{p.name || p.email}
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate">{p.name || p.email}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -60,10 +60,8 @@ describe('ContactDetail', () => {
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
||||
const deleteButton = screen.getAllByRole('button').find(
|
||||
btn => btn.className.includes('text-red')
|
||||
);
|
||||
fireEvent.click(deleteButton!);
|
||||
fireEvent.click(screen.getByLabelText('detail.more_actions'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /context_menu\.delete/ }));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,6 @@ const defaultProps = {
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
categoryLabel: 'All Contacts',
|
||||
selectedContactIds: new Set<string>(),
|
||||
onToggleSelection: vi.fn(),
|
||||
onSelectRangeContacts: vi.fn(),
|
||||
@@ -45,6 +44,9 @@ const defaultProps = {
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkAddToGroup: vi.fn(),
|
||||
onBulkExport: vi.fn(),
|
||||
onEditContact: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
onAddContactToGroup: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ContactList', () => {
|
||||
@@ -76,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
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 { Avatar } from "@/components/ui/avatar";
|
||||
import { Section } from "./contact-detail";
|
||||
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
const EVENT_LIMIT = 5;
|
||||
const EVENT_LOOKAHEAD_DAYS = 365;
|
||||
|
||||
interface ContactActivityProps {
|
||||
contact: ContactCard;
|
||||
}
|
||||
|
||||
function getContactEmails(contact: ContactCard): string[] {
|
||||
if (!contact.emails) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const e of Object.values(contact.emails)) {
|
||||
const addr = e.address?.trim().toLowerCase();
|
||||
if (addr && !seen.has(addr)) {
|
||||
seen.add(addr);
|
||||
result.push(addr);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
for (const addr of addresses) {
|
||||
conditions.push({ from: addr });
|
||||
conditions.push({ to: addr });
|
||||
}
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return { operator: "OR", conditions };
|
||||
}
|
||||
|
||||
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.organizerCalendarAddress) {
|
||||
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
|
||||
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
|
||||
if (addresses.has(org)) return true;
|
||||
}
|
||||
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();
|
||||
const router = useRouter();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const selectEmail = useEmailStore((s) => s.selectEmail);
|
||||
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
|
||||
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
|
||||
|
||||
const [emails, setEmails] = useState<Email[] | null>(null);
|
||||
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
||||
const [emailsLoading, setEmailsLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [emailsError, setEmailsError] = useState(false);
|
||||
const [eventsError, setEventsError] = useState(false);
|
||||
|
||||
const addresses = getContactEmails(contact);
|
||||
const addressKey = addresses.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || addresses.length === 0) {
|
||||
setEmails([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setEmailsLoading(true);
|
||||
setEmailsError(false);
|
||||
client
|
||||
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setEmails(res.emails);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEmailsError(true);
|
||||
setEmails([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setEmailsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || addresses.length === 0) {
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setEventsLoading(true);
|
||||
setEventsError(false);
|
||||
const now = new Date();
|
||||
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
|
||||
const addrSet = new Set(addresses);
|
||||
client
|
||||
.queryAllCalendarEvents(
|
||||
{ after: now.toISOString(), before: before.toISOString() },
|
||||
[{ property: "start", isAscending: true }],
|
||||
500,
|
||||
)
|
||||
.then((all) => {
|
||||
if (cancelled) return;
|
||||
const matching = all
|
||||
.filter((e) => eventInvolvesContact(e, addrSet))
|
||||
.sort((a, b) => a.start.localeCompare(b.start))
|
||||
.slice(0, EVENT_LIMIT);
|
||||
setEvents(matching);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEventsError(true);
|
||||
setEvents([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setEventsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (addresses.length === 0) return null;
|
||||
|
||||
const handleOpenEmail = (email: Email) => {
|
||||
selectEmail(email);
|
||||
router.push("/mail");
|
||||
};
|
||||
|
||||
const handleOpenEvent = (event: CalendarEvent) => {
|
||||
const start = new Date(event.start);
|
||||
if (!isNaN(start.getTime())) setSelectedDate(start);
|
||||
setSelectedEventId(event.id);
|
||||
router.push("/calendar");
|
||||
};
|
||||
|
||||
const formatEmailDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
return format.dateTime(d, sameYear
|
||||
? { month: "short", day: "numeric" }
|
||||
: { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatEventTime = (event: CalendarEvent) => {
|
||||
if (event.showWithoutTime) return t("all_day");
|
||||
const d = new Date(event.start);
|
||||
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 (
|
||||
<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-sm text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("no_emails")}</p>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("upcoming_events")}>
|
||||
{eventsLoading ? (
|
||||
<LoadingRow />
|
||||
) : eventsError ? (
|
||||
<p className="text-sm text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !events || events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("no_events")}</p>
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
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;
|
||||
}
|
||||
|
||||
interface ContactContextMenuProps {
|
||||
contact: ContactCard;
|
||||
position: Position;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
isMultiSelect?: boolean;
|
||||
selectedCount?: number;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onBatchExport?: () => void;
|
||||
onBatchAddToGroup?: () => void;
|
||||
onBatchDelete?: () => void;
|
||||
}
|
||||
|
||||
export function ContactContextMenu({
|
||||
contact,
|
||||
position,
|
||||
isOpen,
|
||||
onClose,
|
||||
menuRef,
|
||||
isMultiSelect = false,
|
||||
selectedCount = 1,
|
||||
onOpen,
|
||||
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) => () => {
|
||||
fn();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSendEmail = () => {
|
||||
if (!email) return;
|
||||
window.location.href = `mailto:${email}`;
|
||||
};
|
||||
|
||||
const handleCall = () => {
|
||||
if (!phone) return;
|
||||
window.location.href = `tel:${phone}`;
|
||||
};
|
||||
|
||||
const handleCopy = async (value: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(t("detail.copied"));
|
||||
} catch {
|
||||
toast.error(t("detail.copy_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
exportContact(contact);
|
||||
toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printContact(contact);
|
||||
};
|
||||
|
||||
if (showBatchActions) {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuHeader>
|
||||
{t("bulk.selected", { count: selectedCount })}
|
||||
</ContextMenuHeader>
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("bulk.add_to_group")}
|
||||
onClick={handle(() => onBatchAddToGroup?.())}
|
||||
disabled={!onBatchAddToGroup}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("bulk.export")}
|
||||
onClick={handle(() => onBatchExport?.())}
|
||||
disabled={!onBatchExport}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("bulk.delete")}
|
||||
onClick={handle(() => onBatchDelete?.())}
|
||||
disabled={!onBatchDelete}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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
|
||||
icon={Users}
|
||||
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}
|
||||
label={t("context_menu.delete")}
|
||||
onClick={handle(onDelete)}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +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;
|
||||
}
|
||||
@@ -26,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 {
|
||||
@@ -40,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;
|
||||
@@ -69,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());
|
||||
@@ -87,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);
|
||||
@@ -96,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;
|
||||
@@ -127,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) : [];
|
||||
@@ -170,255 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
const hasNickname = nicknames.length > 0;
|
||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
||||
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
|
||||
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
{hasNickname && (
|
||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||
)}
|
||||
{titleLine && (
|
||||
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
|
||||
)}
|
||||
{orgs.length > 0 && orgs[0].name && (
|
||||
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
|
||||
{subtitleParts.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-shrink-0 flex-wrap">
|
||||
{email && (
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
{t("detail.compose_email")}
|
||||
</a>
|
||||
)}
|
||||
{phone && (
|
||||
<a
|
||||
href={`tel:${phone}`}
|
||||
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-1" />
|
||||
{t("context_menu.call")}
|
||||
</a>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{/* 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;
|
||||
@@ -427,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">
|
||||
@@ -448,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>
|
||||
@@ -463,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}
|
||||
@@ -476,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
|
||||
@@ -572,7 +716,7 @@ function CopyButton({ value, label, successMsg, failMsg, className }: { value: s
|
||||
toast.error(failMsg);
|
||||
}
|
||||
}}
|
||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
|
||||
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||
|
||||
interface EmailEntry {
|
||||
address: string;
|
||||
@@ -53,55 +54,73 @@ interface ContactFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
||||
|
||||
const formCategoryStyles: Record<FormCategory, string> = {
|
||||
contact: "border-l-blue-400 dark:border-l-blue-500",
|
||||
work: "border-l-amber-400 dark:border-l-amber-500",
|
||||
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
||||
personal: "border-l-violet-400 dark:border-l-violet-500",
|
||||
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
||||
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
||||
notes: "border-l-stone-400 dark:border-l-stone-500",
|
||||
};
|
||||
|
||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
|
||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
category?: FormCategory;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen || !collapsible);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
|
||||
<section className="py-5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
|
||||
collapsible && "hover:text-muted-foreground cursor-pointer",
|
||||
!collapsible && "cursor-default"
|
||||
"flex items-center gap-2 w-full text-left",
|
||||
collapsible ? "cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
onClick={() => collapsible && setOpen(!open)}
|
||||
tabIndex={collapsible ? 0 : -1}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{collapsible && (
|
||||
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-3 pt-3 pb-1">
|
||||
{(open || !collapsible) && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_PHOTO_DIM = 512;
|
||||
const PHOTO_QUALITY = 0.85;
|
||||
|
||||
async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height));
|
||||
const w = Math.max(1, Math.round(img.width * ratio));
|
||||
const h = Math.max(1, Math.round(img.height * ratio));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("canvas-unsupported"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY);
|
||||
resolve({ uri, mediaType: "image/jpeg" });
|
||||
};
|
||||
img.onerror = () => reject(new Error("invalid-image"));
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.onerror = () => reject(new Error("read-failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function Select({ value, onChange, children, className }: {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
@@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
}, [contact]);
|
||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||
|
||||
const initialPhotoEntry = useMemo(() => {
|
||||
if (!contact?.media) return null;
|
||||
for (const [key, m] of Object.entries(contact.media)) {
|
||||
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
|
||||
}
|
||||
return null;
|
||||
}, [contact]);
|
||||
const [photoUri, setPhotoUri] = useState<string | undefined>(initialPhotoEntry?.uri);
|
||||
const [photoMediaType, setPhotoMediaType] = useState<string | undefined>(initialPhotoEntry?.mediaType);
|
||||
const [photoError, setPhotoError] = useState<string | null>(null);
|
||||
const [photoUploading, setPhotoUploading] = useState(false);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setPhotoError(t("photo_invalid"));
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setPhotoError(t("photo_too_large"));
|
||||
return;
|
||||
}
|
||||
setPhotoError(null);
|
||||
setPhotoUploading(true);
|
||||
try {
|
||||
const { uri, mediaType } = await processImageFile(file);
|
||||
setPhotoUri(uri);
|
||||
setPhotoMediaType(mediaType);
|
||||
} catch {
|
||||
setPhotoError(t("photo_invalid"));
|
||||
} finally {
|
||||
setPhotoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoRemove = () => {
|
||||
setPhotoUri(undefined);
|
||||
setPhotoMediaType(undefined);
|
||||
setPhotoError(null);
|
||||
};
|
||||
|
||||
const validateEmail = (address: string): boolean => {
|
||||
if (!address.trim()) return true;
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
|
||||
@@ -427,6 +490,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
});
|
||||
}
|
||||
|
||||
const mediaMap: Record<string, ContactMedia> = {};
|
||||
if (contact?.media) {
|
||||
for (const [key, m] of Object.entries(contact.media)) {
|
||||
if (m.kind !== "photo") mediaMap[key] = m;
|
||||
}
|
||||
}
|
||||
if (photoUri) {
|
||||
const photoKey = initialPhotoEntry?.key || "photo";
|
||||
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
|
||||
}
|
||||
|
||||
const data: Partial<ContactCard> = {
|
||||
name: { components: nameComponents, isOrdered: true },
|
||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||
@@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
calendarUri: calendarUri.trim() || undefined,
|
||||
schedulingUri: schedulingUri.trim() || undefined,
|
||||
freeBusyUri: freeBusyUri.trim() || undefined,
|
||||
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
|
||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||
};
|
||||
|
||||
@@ -466,6 +541,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
}
|
||||
};
|
||||
|
||||
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
|
||||
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
@@ -478,38 +556,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4">
|
||||
<div className="px-6 py-4 max-w-3xl">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
|
||||
{/* Address Book Selector */}
|
||||
{addressBooks && addressBooks.length > 1 && (
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"} category="contact">
|
||||
<select
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
<div className="flex items-center gap-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
disabled={photoUploading}
|
||||
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
|
||||
title={t("upload_photo")}
|
||||
aria-label={t("upload_photo")}
|
||||
>
|
||||
<Avatar
|
||||
name={previewName || undefined}
|
||||
email={previewEmail || undefined}
|
||||
contactPhotoUri={photoUri}
|
||||
size="lg"
|
||||
className="!w-20 !h-20 !text-xl"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
|
||||
>
|
||||
<Camera className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
|
||||
</span>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handlePhotoSelect}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
|
||||
{photoError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
|
||||
)}
|
||||
{photoUri && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePhotoRemove}
|
||||
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormSection>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("remove_photo")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60">
|
||||
|
||||
{addressBooks && addressBooks.length > 1 && (
|
||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
|
||||
<select
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{/* Name & Identity - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||
<FormSection icon={User} title={t("section_identity")}>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
|
||||
<FormSection icon={Mail} title={t("email")}>
|
||||
<div className="space-y-2">
|
||||
{emails.map((entry, i) => (
|
||||
<div key={i}>
|
||||
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Phone */}
|
||||
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
|
||||
<FormSection icon={Phone} title={t("phone")}>
|
||||
<div className="space-y-2">
|
||||
{phones.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Work & Organization */}
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Addresses - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||
{/* Addresses */}
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
|
||||
<div className="space-y-3">
|
||||
{addresses.map((addr, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
{/* Online Services */}
|
||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
|
||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{onlineServices.map((svc, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Anniversaries */}
|
||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{anniversaries.map((ann, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Personal Info */}
|
||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{personalInfoEntries.map((pi, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Categories */}
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
|
||||
<CategoryComboBox
|
||||
keywordsStr={keywordsStr}
|
||||
onChange={setKeywordsStr}
|
||||
@@ -828,7 +947,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Gender */}
|
||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
|
||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</FormSection>
|
||||
|
||||
{/* Calendar */}
|
||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
|
||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
||||
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Notes - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||
{/* Notes */}
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
@@ -876,7 +994,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,10 @@ interface ContactListItemProps {
|
||||
selectedContactIds: Set<string>;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
|
||||
}
|
||||
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const org = contact.organizations
|
||||
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
|
||||
className={cn(
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||
isSelected
|
||||
|
||||
@@ -1,16 +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;
|
||||
@@ -18,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;
|
||||
@@ -28,6 +109,9 @@ interface ContactListProps {
|
||||
onBulkDelete: () => void;
|
||||
onBulkAddToGroup: () => void;
|
||||
onBulkExport: () => void;
|
||||
onEditContact: (id: string) => void;
|
||||
onDeleteContact: (contact: ContactCard) => void;
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
@@ -37,7 +121,6 @@ export function ContactList({
|
||||
onSearchChange,
|
||||
onSelectContact,
|
||||
onCreateNew,
|
||||
categoryLabel,
|
||||
className,
|
||||
selectedContactIds,
|
||||
onToggleSelection,
|
||||
@@ -47,32 +130,99 @@ export function ContactList({
|
||||
onBulkDelete,
|
||||
onBulkAddToGroup,
|
||||
onBulkExport,
|
||||
onEditContact,
|
||||
onDeleteContact,
|
||||
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) => {
|
||||
@@ -84,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">
|
||||
@@ -157,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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -184,8 +532,8 @@ export function ContactList({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{sorted.map((contact) => (
|
||||
(() => {
|
||||
const renderItem = (contact: ContactCard) => (
|
||||
<ContactListItem
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
@@ -210,11 +558,45 @@ export function ContactList({
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
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>
|
||||
|
||||
{contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={contextMenu.data}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
menuRef={menuRef}
|
||||
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedContactIds.size}
|
||||
onOpen={() => onSelectContact(contextMenu.data!.id)}
|
||||
onEdit={() => onEditContact(contextMenu.data!.id)}
|
||||
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||
onBatchExport={onBulkExport}
|
||||
onBatchAddToGroup={onBulkAddToGroup}
|
||||
onBatchDelete={onBulkDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function printContact(contact: ContactCard, displayName?: string): void {
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
||||
const name = displayName || getContactDisplayName(contact);
|
||||
const photoUri = getContactPhotoUri(contact);
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
const titles = contact.titles ? Object.values(contact.titles) : [];
|
||||
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
|
||||
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
|
||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
|
||||
|
||||
const rows: string[] = [];
|
||||
const section = (title: string, items: string[]) => {
|
||||
if (items.length === 0) return;
|
||||
rows.push(`<section><h2>${escapeHtml(title)}</h2><dl>${items.join("")}</dl></section>`);
|
||||
};
|
||||
const row = (label: string, value: string) =>
|
||||
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
|
||||
|
||||
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
|
||||
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
|
||||
section(
|
||||
"Organization",
|
||||
orgs.map(o => {
|
||||
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
|
||||
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
|
||||
return row("Organization", value);
|
||||
}),
|
||||
);
|
||||
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
|
||||
section(
|
||||
"Address",
|
||||
addresses.map(a => {
|
||||
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
|
||||
.map(p => (typeof p === "string" ? p : ""))
|
||||
.filter(Boolean);
|
||||
return row(a.label || "Address", parts.join(", "));
|
||||
}),
|
||||
);
|
||||
section(
|
||||
"Online",
|
||||
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
|
||||
);
|
||||
section(
|
||||
"Anniversary",
|
||||
anniversaries.map(a => {
|
||||
const date = a.date;
|
||||
let formatted = "";
|
||||
if (typeof date === "object" && date !== null) {
|
||||
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
|
||||
else {
|
||||
const parts: string[] = [];
|
||||
const pd = date as PartialDate;
|
||||
if (pd.year) parts.push(String(pd.year));
|
||||
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
|
||||
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
|
||||
formatted = parts.join("-");
|
||||
}
|
||||
} else if (date) {
|
||||
formatted = String(date);
|
||||
}
|
||||
return row(a.kind || "Date", formatted);
|
||||
}),
|
||||
);
|
||||
section("Notes", notes.map(n => row("", n.note || "")));
|
||||
|
||||
const photoTag = photoUri
|
||||
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
|
||||
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(name || "Contact")}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
|
||||
header { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ccc; }
|
||||
.photo { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
.photo.placeholder { background: #e5e7eb; color: #4b5563; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 600; }
|
||||
h1 { margin: 0; font-size: 22px; }
|
||||
section { margin-top: 20px; }
|
||||
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 0 0 8px; }
|
||||
dl { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 6px; column-gap: 16px; font-size: 14px; }
|
||||
dt { color: #555; }
|
||||
dd { margin: 0; word-break: break-word; }
|
||||
@media print { body { margin: 16mm; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
${photoTag}
|
||||
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
|
||||
</header>
|
||||
${rows.join("")}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
X,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
@@ -372,6 +373,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const [showCalendarPicker, setShowCalendarPicker] = useState(false);
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('');
|
||||
const [rawIcsMethod, setRawIcsMethod] = useState<InvitationMethod>('unknown');
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const attachment = findCalendarAttachment(email);
|
||||
|
||||
@@ -442,6 +444,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const summary = parsedEvent ? formatEventSummary(parsedEvent) : null;
|
||||
const isCancellation = method === 'cancel';
|
||||
const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter';
|
||||
const canCollapse = method === 'reply';
|
||||
const showDetails = !canCollapse || !isCollapsed;
|
||||
const allowsRsvp = method === 'request';
|
||||
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
||||
|
||||
@@ -750,9 +754,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground truncate">{bannerTitle}</span>
|
||||
</div>
|
||||
{canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||
title={isCollapsed ? t('expand') : t('collapse')}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{showDetails && (
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
<div className="lg:flex lg:gap-6">
|
||||
{/* Left: Event info */}
|
||||
@@ -894,8 +911,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showDetails && (
|
||||
<div className="px-4 py-2.5 border-t border-border bg-muted/20 flex items-center gap-2 flex-wrap">
|
||||
{canRespond && (
|
||||
<>
|
||||
@@ -1020,6 +1039,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ export function EmailComposer({
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
// Skip inline cid-referenced images — they're embedded in the forwarded HTML body
|
||||
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||
// (matches the viewer's hideInlineImageAttachments logic).
|
||||
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map(att => ({
|
||||
|
||||
@@ -99,7 +99,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||
// Add visual feedback for checked state
|
||||
@@ -302,7 +303,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={colorTag ? colorTag : (selected ? "bg-selection" : "bg-muted")}
|
||||
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function EmailList({
|
||||
batchMarkAsRead,
|
||||
batchDelete,
|
||||
batchMoveToMailbox,
|
||||
batchArchive,
|
||||
batchMarkAsSpam,
|
||||
batchUndoSpam,
|
||||
loadMoreEmails,
|
||||
@@ -497,12 +498,12 @@ export function EmailList({
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
if (!onArchive) return;
|
||||
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
|
||||
for (const email of selected) {
|
||||
await onArchive(email);
|
||||
if (!client) return;
|
||||
try {
|
||||
await batchArchive(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to batch archive:', error);
|
||||
}
|
||||
clearSelection();
|
||||
}}
|
||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||
onBatchMarkAsSpam={async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
|
||||
import ReactDOM from "react-dom";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -2322,7 +2322,7 @@ export function EmailViewer({
|
||||
let blockedExternalContent = false;
|
||||
|
||||
// Use shared sanitization config as base (more secure)
|
||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||
const sanitizeConfig = { ...EMAIL_IFRAME_SANITIZE_CONFIG };
|
||||
|
||||
// Check if sender is trusted (localStorage list or address book)
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
@@ -2438,7 +2438,7 @@ export function EmailViewer({
|
||||
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
}
|
||||
);
|
||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG);
|
||||
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (smimeDecryptedText) {
|
||||
@@ -2446,7 +2446,7 @@ export function EmailViewer({
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_SANITIZE_CONFIG);
|
||||
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (tnefText) {
|
||||
@@ -2454,7 +2454,7 @@ export function EmailViewer({
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG);
|
||||
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (embeddedEmailText) {
|
||||
@@ -2603,7 +2603,7 @@ export function EmailViewer({
|
||||
return `<!DOCTYPE html>
|
||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
@@ -2880,7 +2880,7 @@ export function EmailViewer({
|
||||
<>
|
||||
{/* Left: Reply actions */}
|
||||
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
|
||||
{showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
{showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -3628,7 +3628,7 @@ export function EmailViewer({
|
||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
{/* Back button (for below-subject mode on tablet) */}
|
||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -3676,8 +3676,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Date/time on the right of subject row */}
|
||||
<div className="flex-shrink-0 text-right">
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||
<div className="hidden sm:block flex-shrink-0 text-right">
|
||||
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
@@ -4484,6 +4484,17 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Date/time + size on the right (mobile) */}
|
||||
<div className="sm:hidden flex-shrink-0 text-right ml-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
{email.size > 0 && (
|
||||
<div className="text-xs text-muted-foreground/60">
|
||||
{formatFileSize(email.size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -133,7 +133,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !resolvedColorTag && "shadow-sm",
|
||||
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
@@ -323,7 +324,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : (selected ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
@@ -487,7 +488,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50",
|
||||
@@ -725,7 +727,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : (isSelected ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { usePathname, Link } from "@/i18n/navigation";
|
||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||
import NextLink from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -18,7 +18,7 @@ import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials } from "@/lib/account-utils";
|
||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
@@ -163,6 +163,7 @@ export function NavigationRail({
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
@@ -223,11 +224,12 @@ export function NavigationRail({
|
||||
let cancelled = false;
|
||||
const headers = getActiveAccountSlotHeaders();
|
||||
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||
apiFetch('/api/admin/stalwart-check', { headers })
|
||||
apiFetch('/api/admin/auth', { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!cancelled && data.isStalwartAdmin) {
|
||||
setIsStalwartAdmin(true);
|
||||
if (cancelled || !data.stalwartAdmin) return;
|
||||
setIsStalwartAdmin(true);
|
||||
if (!data.authenticated) {
|
||||
// Pre-create admin session so /admin works even after full page navigation
|
||||
apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
@@ -605,6 +607,16 @@ export function NavigationRail({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{accounts.length < MAX_ACCOUNTS && (
|
||||
<button
|
||||
onClick={() => router.push(`/login?mode=add-account` as never)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
|
||||
title={t("add_account")}
|
||||
aria-label={t("add_account")}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout button with popover */}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { useAccountSecurityStore } from '@/stores/account-security-store';
|
||||
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -172,37 +174,95 @@ function DisplayNameSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'Stalwart',
|
||||
label: accountLabel || 'account',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: new OTPAuth.Secret({ size: 20 }),
|
||||
});
|
||||
return { totp, url: totp.toString() };
|
||||
}
|
||||
|
||||
function TotpSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [totpUrl, setTotpUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
|
||||
const [setupUrl, setSetupUrl] = useState<string | null>(null);
|
||||
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [disableOpen, setDisableOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupUrl) { setQrDataUrl(null); return; }
|
||||
let cancelled = false;
|
||||
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
|
||||
.then((url) => { if (!cancelled) setQrDataUrl(url); })
|
||||
.catch(() => { /* ignore */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [setupUrl]);
|
||||
|
||||
const startSetup = () => {
|
||||
const { totp, url } = generateTotp(client?.getUsername() ?? 'account');
|
||||
setSetupTotp(totp);
|
||||
setSetupUrl(url);
|
||||
setPassword('');
|
||||
setOtpCode('');
|
||||
setSetupError(null);
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
setSetupTotp(null);
|
||||
setSetupUrl(null);
|
||||
setPassword('');
|
||||
setOtpCode('');
|
||||
setSetupError(null);
|
||||
};
|
||||
|
||||
const confirmSetup = async () => {
|
||||
if (!setupTotp || !setupUrl) return;
|
||||
if (!password) { setSetupError(t('totp.password_required')); return; }
|
||||
if (!otpCode.trim()) { setSetupError(t('totp.code_required')); return; }
|
||||
if (setupTotp.validate({ token: otpCode.trim(), window: 1 }) === null) {
|
||||
setSetupError(t('totp.code_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleToggle = async (enable: boolean) => {
|
||||
try {
|
||||
if (enable) {
|
||||
const url = await enableTotp();
|
||||
setTotpUrl(url);
|
||||
toast.success(t('totp.enabled'));
|
||||
} else {
|
||||
await disableTotp();
|
||||
setTotpUrl(null);
|
||||
toast.success(t('totp.disabled'));
|
||||
}
|
||||
await enableTotp(password, setupUrl, otpCode.trim());
|
||||
cancelSetup();
|
||||
toast.success(t('totp.enabled'));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
enable ? t('totp.enable_error') : t('totp.disable_error'),
|
||||
err instanceof Error ? err.message : undefined
|
||||
);
|
||||
setSetupError(err instanceof Error ? err.message : t('totp.enable_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (totpUrl) {
|
||||
navigator.clipboard.writeText(totpUrl).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
const handleDisable = async () => {
|
||||
if (!password) { setSetupError(t('totp.password_required')); return; }
|
||||
try {
|
||||
await disableTotp(password);
|
||||
setDisableOpen(false);
|
||||
setPassword('');
|
||||
setSetupError(null);
|
||||
toast.success(t('totp.disabled'));
|
||||
} catch (err) {
|
||||
setSetupError(err instanceof Error ? err.message : t('totp.disable_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (enable: boolean) => {
|
||||
setSetupError(null);
|
||||
if (enable) {
|
||||
startSetup();
|
||||
} else {
|
||||
setDisableOpen(true);
|
||||
setPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,30 +278,65 @@ function TotpSection() {
|
||||
<div className="space-y-3">
|
||||
<SettingItem label={t('totp.label')} description={t('totp.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
checked={otpEnabled}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
checked={otpEnabled || !!setupUrl}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{otpEnabled ? t('totp.active') : t('totp.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{totpUrl && (
|
||||
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
|
||||
{setupUrl && (
|
||||
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
|
||||
{qrDataUrl && (
|
||||
<div className="flex justify-center">
|
||||
<img src={qrDataUrl} alt="TOTP QR code" className="rounded bg-white p-2" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
|
||||
{totpUrl}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">{setupUrl}</code>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
|
||||
<Input value={otpCode} onChange={(e) => setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} />
|
||||
</div>
|
||||
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t('totp.confirm')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disableOpen && (
|
||||
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('password.current')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t('totp.disable')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
|
||||
{t('app_passwords.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,58 +345,113 @@ function TotpSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordsSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
function parseIpList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const generatePassword = useCallback(() => {
|
||||
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let result = '';
|
||||
const array = new Uint8Array(24);
|
||||
crypto.getRandomValues(array);
|
||||
for (const byte of array) {
|
||||
result += chars[byte % chars.length];
|
||||
}
|
||||
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
return result.match(/.{1,4}/g)?.join('-') ?? result;
|
||||
}, []);
|
||||
function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between py-2 px-3 bg-muted/50 rounded-md gap-2">
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm text-foreground truncate">{entry.description || entry.id}</span>
|
||||
{entry.createdAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{entry.allowedIps.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{entry.allowedIps.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="text-[10px] font-mono bg-background border border-border rounded px-1.5 py-0.5 text-muted-foreground"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(entry.id)}
|
||||
disabled={isSaving}
|
||||
className="text-destructive hover:text-destructive shrink-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialSectionProps {
|
||||
icon: typeof Smartphone;
|
||||
i18nNamespace: 'app_passwords' | 'api_keys';
|
||||
entries: Array<AppPasswordInfo | ApiKeyInfo>;
|
||||
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) {
|
||||
const t = useTranslations('settings.security');
|
||||
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
|
||||
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
|
||||
const password = newPassword || generatePassword();
|
||||
if (!newDescription.trim()) return;
|
||||
|
||||
try {
|
||||
await addAppPassword(newName.trim(), password);
|
||||
setNewName('');
|
||||
setNewPassword('');
|
||||
const result = await onCreate({
|
||||
description: newDescription.trim(),
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||
allowedIps: parseIpList(allowedIpsRaw),
|
||||
});
|
||||
setCreatedSecret(result.secret);
|
||||
setNewDescription('');
|
||||
setExpiresAt('');
|
||||
setAllowedIpsRaw('');
|
||||
setShowAdd(false);
|
||||
toast.success(t('app_passwords.added'));
|
||||
toast.success(tk('added'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
|
||||
toast.error(tk('add_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
const handleRemove = async (id: string) => {
|
||||
try {
|
||||
await removeAppPassword(name);
|
||||
toast.success(t('app_passwords.removed'));
|
||||
await onRemove(id);
|
||||
toast.success(tk('removed'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
|
||||
toast.error(tk('remove_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = () => {
|
||||
if (!createdSecret) return;
|
||||
navigator.clipboard.writeText(createdSecret).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
|
||||
</div>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -312,53 +462,61 @@ function AppPasswordsSection() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{t('app_passwords.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
|
||||
<p className="text-xs text-muted-foreground">{tk('description')}</p>
|
||||
|
||||
{createdSecret && (
|
||||
<div className="p-3 bg-muted rounded-md space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{tk('copy_now_warning')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono break-all">
|
||||
{createdSecret}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopySecret}>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreatedSecret(null)}>
|
||||
{t('app_passwords.done')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('app_passwords.name_placeholder')}
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder={tk('name_placeholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={t('app_passwords.password_placeholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
|
||||
{t('app_passwords.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
|
||||
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.allowed_ips_label')}</label>
|
||||
<textarea
|
||||
value={allowedIpsRaw}
|
||||
onChange={(e) => setAllowedIpsRaw(e.target.value)}
|
||||
placeholder={t('app_passwords.allowed_ips_placeholder')}
|
||||
rows={2}
|
||||
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
|
||||
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t('app_passwords.create')}
|
||||
</Button>
|
||||
@@ -369,47 +527,48 @@ function AppPasswordsSection() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{appPasswords.length > 0 ? (
|
||||
{entries.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{appPasswords.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
|
||||
<span className="text-sm text-foreground">{name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(name)}
|
||||
disabled={isSaving}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{entries.map((entry) => (
|
||||
<CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
|
||||
<p className="text-xs text-muted-foreground italic">{tk('none')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordsSection() {
|
||||
const { appPasswords, createAppPassword, removeAppPassword } = useAccountSecurityStore();
|
||||
return (
|
||||
<CredentialSection
|
||||
icon={Smartphone}
|
||||
i18nNamespace="app_passwords"
|
||||
entries={appPasswords}
|
||||
onCreate={createAppPassword}
|
||||
onRemove={removeAppPassword}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysSection() {
|
||||
const { apiKeys, createApiKey, removeApiKey } = useAccountSecurityStore();
|
||||
return (
|
||||
<CredentialSection
|
||||
icon={Terminal}
|
||||
i18nNamespace="api_keys"
|
||||
entries={apiKeys}
|
||||
onCreate={createApiKey}
|
||||
onRemove={removeApiKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EncryptionSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
if (enabled) {
|
||||
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
|
||||
toast.success(t('encryption.enabled'));
|
||||
} else {
|
||||
await updateEncryption({ type: 'disabled' });
|
||||
toast.success(t('encryption.disabled_success'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
|
||||
|
||||
if (isLoadingCrypto) {
|
||||
return (
|
||||
@@ -419,24 +578,12 @@ function EncryptionSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const isEnabled = encryptionType !== 'disabled';
|
||||
|
||||
const isEnabled = encryptionType !== 'Disabled';
|
||||
return (
|
||||
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
checked={isEnabled}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')}
|
||||
</span>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
@@ -446,7 +593,7 @@ function EmailClientSection() {
|
||||
const { client } = useAuthStore();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const jmapUsername = client?.getUsername() || '';
|
||||
const jmapUsername = useMemo(() => client?.getUsername() || '', [client]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(jmapUsername).then(() => {
|
||||
@@ -551,6 +698,9 @@ export function AccountSecuritySettings() {
|
||||
|
||||
<AppPasswordsSection />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
<ApiKeysSection />
|
||||
|
||||
{isOAuth && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { useSettingsStore, type Density } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||
@@ -66,16 +64,15 @@ function DensityPreview({ density }: { density: Density }) {
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Theme */}
|
||||
<SettingItem label={t('theme.label')} description={t('theme.description')}>
|
||||
<RadioGroup
|
||||
value={theme}
|
||||
@@ -88,12 +85,6 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Language */}
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
{/* Font Size */}
|
||||
{!isSettingHidden('fontSize') && (
|
||||
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
|
||||
<RadioGroup
|
||||
@@ -108,7 +99,6 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Density */}
|
||||
{!isSettingHidden('density') && (
|
||||
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
|
||||
<RadioGroup
|
||||
@@ -127,64 +117,6 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Toolbar Position */}
|
||||
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
|
||||
<RadioGroup
|
||||
value={toolbarPosition}
|
||||
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
|
||||
options={[
|
||||
{ value: 'top', label: t('toolbar_position.top') },
|
||||
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Toolbar Labels */}
|
||||
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showToolbarLabels}
|
||||
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Hide Account Switcher */}
|
||||
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideAccountSwitcher}
|
||||
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Show Rail Account List */}
|
||||
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showRailAccountList}
|
||||
onChange={(checked) => updateSetting('showRailAccountList', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Colorful Sidebar Icons */}
|
||||
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
|
||||
<ToggleSwitch
|
||||
checked={colorfulSidebarIcons}
|
||||
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Unified Mailbox */}
|
||||
{accounts.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Animations */}
|
||||
{!isSettingHidden('animationsEnabled') && (
|
||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||
<ToggleSwitch
|
||||
@@ -194,7 +126,10 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Restart Tour */}
|
||||
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
|
||||
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Mail, X } from 'lucide-react';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
autoSelectReplyIdentity,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
|
||||
setDefaultMailStatus('success');
|
||||
}
|
||||
} catch {
|
||||
setDefaultMailStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||
<ToggleSwitch
|
||||
checked={autoSelectReplyIdentity}
|
||||
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
{attachmentReminderEnabled && (
|
||||
<div className="py-3 border-b border-border space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachmentReminderKeywords.map((kw) => (
|
||||
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('attachment_reminder.remove')}
|
||||
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newKeyword.trim().toLowerCase();
|
||||
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
||||
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder={t('attachment_reminder.add_placeholder')}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newKeyword.trim()}
|
||||
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('attachment_reminder.add')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleSetDefaultMailProgram}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
|
||||
</button>
|
||||
{defaultMailStatus === 'success' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
|
||||
)}
|
||||
{defaultMailStatus === 'error' && (
|
||||
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem } from "./settings-section";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
export function ContactsSettings() {
|
||||
@@ -20,6 +21,8 @@ export function ContactsSettings() {
|
||||
supportsSync,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
const individuals = contacts.filter(c => c.kind !== "group");
|
||||
@@ -55,6 +58,16 @@ export function ContactsSettings() {
|
||||
title={tSettings("title")}
|
||||
description={tSettings("description")}
|
||||
>
|
||||
<SettingItem
|
||||
label={tSettings("group_by_letter_label")}
|
||||
description={tSettings("group_by_letter_description")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={groupContactsByLetter}
|
||||
onChange={(checked) => updateSetting("groupContactsByLetter", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={tSettings("import_label")}
|
||||
description={tSettings("import_description")}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
export function ContentSendersSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
const {
|
||||
externalContentPolicy,
|
||||
emailAlwaysLightMode,
|
||||
trustedSenders,
|
||||
trustedSendersAddressBook,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { trustedSenderEmails } = useContactStore();
|
||||
|
||||
const getTrustedSendersCount = () => {
|
||||
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
|
||||
if (count === 0) return t('trusted_senders.count_zero');
|
||||
if (count === 1) return t('trusted_senders.count_one');
|
||||
return t('trusted_senders.count_other', { count });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{!isSettingHidden('externalContentPolicy') && (
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
onChange={(value) =>
|
||||
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
|
||||
}
|
||||
options={[
|
||||
{ value: 'ask', label: t('external_content.ask') },
|
||||
{ value: 'block', label: t('external_content.block') },
|
||||
{ value: 'allow', label: t('external_content.allow') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
|
||||
<ToggleSwitch
|
||||
checked={emailAlwaysLightMode}
|
||||
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
|
||||
<button
|
||||
onClick={() => setShowTrustedModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||
<ToggleSwitch
|
||||
checked={trustedSendersAddressBook}
|
||||
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<TrustedSendersModal
|
||||
isOpen={showTrustedModal}
|
||||
onClose={() => setShowTrustedModal(false)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
export function DebugSettings() {
|
||||
const t = useTranslations('settings.advanced');
|
||||
const { debugMode, debugCategories, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
|
||||
if (isSettingHidden('debugMode') || !isFeatureEnabled('debugModeEnabled')) {
|
||||
return (
|
||||
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
|
||||
<p className="text-sm text-muted-foreground py-2">{t('debug_mode.description')}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('debug_mode.label')} description={t('debug_mode.description')}>
|
||||
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
|
||||
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
{debugMode && (
|
||||
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
|
||||
{ALL_DEBUG_CATEGORIES.map((cat) => (
|
||||
<SettingItem
|
||||
key={cat.id}
|
||||
label={t(`debug_categories.${cat.labelKey}`)}
|
||||
description={t(`debug_categories.${cat.labelKey}_description`)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={debugCategories?.[cat.id] !== false}
|
||||
onChange={(checked) => {
|
||||
updateSetting('debugCategories', {
|
||||
...debugCategories,
|
||||
[cat.id]: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('language.label')} description={t('language.description')}>
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
|
||||
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||
];
|
||||
|
||||
function MailLayoutPreview({
|
||||
value,
|
||||
t,
|
||||
}: {
|
||||
value: MailLayout;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const isSplit = value === 'split';
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex h-28">
|
||||
<div className="w-11 border-r border-border bg-muted/40" />
|
||||
|
||||
{isSplit ? (
|
||||
<>
|
||||
<div className="w-28 border-r border-border bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium text-foreground">{row.sender}</div>
|
||||
<div className="truncate text-muted-foreground">{row.subject}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 bg-background px-3 py-2">
|
||||
<div className="h-2.5 w-20 rounded bg-foreground/10" />
|
||||
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 bg-background px-2 py-2">
|
||||
<div className="space-y-1.5">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[10px]',
|
||||
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
<span className="font-medium">{row.sender}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{!isSettingHidden('mailLayout') && (
|
||||
<SettingItem label={tEmail('mail_layout.label')} description={tEmail('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||
<div className="w-[22rem] max-w-full">
|
||||
<RadioGroup
|
||||
value={mailLayout}
|
||||
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
|
||||
options={[
|
||||
{ value: 'split', label: tEmail('mail_layout.split') },
|
||||
{ value: 'focus', label: tEmail('mail_layout.focus') },
|
||||
]}
|
||||
/>
|
||||
<MailLayoutPreview value={mailLayout} t={tEmail} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
|
||||
<RadioGroup
|
||||
value={toolbarPosition}
|
||||
onChange={(value) => updateSetting('toolbarPosition', value as ToolbarPosition)}
|
||||
options={[
|
||||
{ value: 'top', label: t('toolbar_position.top') },
|
||||
{ value: 'below-subject', label: t('toolbar_position.below_subject') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showToolbarLabels}
|
||||
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('hide_account_switcher.label')} description={t('hide_account_switcher.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideAccountSwitcher}
|
||||
onChange={(checked) => updateSetting('hideAccountSwitcher', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('show_rail_account_list.label')} description={t('show_rail_account_list.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showRailAccountList}
|
||||
onChange={(checked) => updateSetting('showRailAccountList', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
|
||||
<ToggleSwitch
|
||||
checked={colorfulSidebarIcons}
|
||||
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store';
|
||||
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
|
||||
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail, X } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
{ sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true },
|
||||
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||
];
|
||||
|
||||
function MailLayoutPreview({
|
||||
value,
|
||||
t,
|
||||
}: {
|
||||
value: MailLayout;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const isSplit = value === 'split';
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex h-28">
|
||||
<div className="w-11 border-r border-border bg-muted/40" />
|
||||
|
||||
{isSplit ? (
|
||||
<>
|
||||
<div className="w-28 border-r border-border bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate font-medium text-foreground">{row.sender}</div>
|
||||
<div className="truncate text-muted-foreground">{row.subject}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 bg-background px-3 py-2">
|
||||
<div className="h-2.5 w-20 rounded bg-foreground/10" />
|
||||
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 bg-background px-2 py-2">
|
||||
<div className="space-y-1.5">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[10px]',
|
||||
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
<span className="font-medium">{row.sender}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||
<span className="text-muted-foreground/80">{row.preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailSettings() {
|
||||
export function ReadingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
const [isReorganizing, setIsReorganizing] = useState(false);
|
||||
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
|
||||
setDefaultMailStatus('success');
|
||||
}
|
||||
} catch {
|
||||
setDefaultMailStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
markAsReadDelay,
|
||||
deleteAction,
|
||||
@@ -120,33 +25,17 @@ export function EmailSettings() {
|
||||
showPreview,
|
||||
mailLayout,
|
||||
disableThreading,
|
||||
autoSelectReplyIdentity,
|
||||
plainTextMode,
|
||||
emailsPerPage,
|
||||
externalContentPolicy,
|
||||
mailAttachmentAction,
|
||||
attachmentPosition,
|
||||
emailAlwaysLightMode,
|
||||
archiveMode,
|
||||
hoverActions,
|
||||
hoverActionsMode,
|
||||
hoverActionsCorner,
|
||||
trustedSenders,
|
||||
trustedSendersAddressBook,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
hideInlineImageAttachments,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { trustedSenderEmails } = useContactStore();
|
||||
|
||||
// Get count label for trusted senders button
|
||||
const getTrustedSendersCount = () => {
|
||||
const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length;
|
||||
if (count === 0) return t('trusted_senders.count_zero');
|
||||
if (count === 1) return t('trusted_senders.count_one');
|
||||
return t('trusted_senders.count_other', { count });
|
||||
};
|
||||
|
||||
const isFocusedLayout = mailLayout === 'focus';
|
||||
|
||||
@@ -163,8 +52,6 @@ export function EmailSettings() {
|
||||
|
||||
try {
|
||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||
|
||||
// Fetch all emails in the root archive mailbox
|
||||
const emails = await client.getEmailsInMailbox(archiveId);
|
||||
let movedCount = 0;
|
||||
|
||||
@@ -173,10 +60,8 @@ export function EmailSettings() {
|
||||
const year = emailDate.getFullYear().toString();
|
||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
|
||||
// Re-read mailboxes from store each iteration in case new ones were created
|
||||
let currentMailboxes = useEmailStore.getState().mailboxes;
|
||||
|
||||
// Find or create year subfolder
|
||||
let yearMailbox = currentMailboxes.find(
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
@@ -190,7 +75,6 @@ export function EmailSettings() {
|
||||
await client.moveEmail(email.id, yearMailbox.id);
|
||||
movedCount++;
|
||||
} else {
|
||||
// month mode
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = currentMailboxes.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
@@ -215,7 +99,6 @@ export function EmailSettings() {
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Mark as Read */}
|
||||
{!isSettingHidden('markAsReadDelay') && (
|
||||
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
|
||||
<Select
|
||||
@@ -231,7 +114,6 @@ export function EmailSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Delete Action */}
|
||||
{!isSettingHidden('deleteAction') && (
|
||||
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -253,7 +135,6 @@ export function EmailSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Archive Mode */}
|
||||
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
@@ -287,7 +168,6 @@ export function EmailSettings() {
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Permanently Delete Junk */}
|
||||
<SettingItem label={t('permanently_delete_junk.label')} description={t('permanently_delete_junk.description')}>
|
||||
<ToggleSwitch
|
||||
checked={permanentlyDeleteJunk}
|
||||
@@ -295,23 +175,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{!isSettingHidden('mailLayout') && (
|
||||
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
|
||||
<div className="w-[22rem] max-w-full">
|
||||
<RadioGroup
|
||||
value={mailLayout}
|
||||
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
|
||||
options={[
|
||||
{ value: 'split', label: t('mail_layout.split') },
|
||||
{ value: 'focus', label: t('mail_layout.focus') },
|
||||
]}
|
||||
/>
|
||||
<MailLayoutPreview value={mailLayout} t={t} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Show Preview */}
|
||||
{!isSettingHidden('showPreview') && (
|
||||
<SettingItem
|
||||
label={t('show_preview.label')}
|
||||
@@ -322,7 +185,6 @@ export function EmailSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Disable Thread Grouping */}
|
||||
<SettingItem label={t('disable_threading.label')} description={t('disable_threading.description')}>
|
||||
<ToggleSwitch
|
||||
checked={disableThreading}
|
||||
@@ -330,7 +192,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Plain Text Mode */}
|
||||
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
|
||||
<ToggleSwitch
|
||||
checked={plainTextMode}
|
||||
@@ -338,71 +199,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||
<ToggleSwitch
|
||||
checked={autoSelectReplyIdentity}
|
||||
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Attachment Reminder */}
|
||||
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
|
||||
<ToggleSwitch
|
||||
checked={attachmentReminderEnabled}
|
||||
onChange={(checked) => updateSetting('attachmentReminderEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
{attachmentReminderEnabled && (
|
||||
<div className="py-3 border-b border-border space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{t('attachment_reminder.keywords_label')}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('attachment_reminder.keywords_description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{attachmentReminderKeywords.map((kw) => (
|
||||
<span key={kw} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-foreground">
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('attachment_reminder.remove')}
|
||||
onClick={() => updateSetting('attachmentReminderKeywords', attachmentReminderKeywords.filter(k => k !== kw))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newKeyword.trim().toLowerCase();
|
||||
if (trimmed && !attachmentReminderKeywords.includes(trimmed)) {
|
||||
updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder={t('attachment_reminder.add_placeholder')}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newKeyword.trim()}
|
||||
className="px-3 py-1 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('attachment_reminder.add')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hide inline images from attachment list */}
|
||||
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideInlineImageAttachments}
|
||||
@@ -410,7 +206,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Quick Hover Actions */}
|
||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||
<div className="py-3 border-b border-border space-y-3">
|
||||
<div>
|
||||
@@ -443,7 +238,6 @@ export function EmailSettings() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hover Actions Display Mode */}
|
||||
<div className="pt-2 space-y-2">
|
||||
<label className="text-xs font-medium text-foreground">{t('hover_actions.mode_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -465,7 +259,6 @@ export function EmailSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner Selection (only when floating) */}
|
||||
{hoverActionsMode === 'floating' && (
|
||||
<div className="pt-1 space-y-2">
|
||||
<label className="text-xs font-medium text-foreground">{t('hover_actions.corner_label')}</label>
|
||||
@@ -513,7 +306,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Emails Per Page */}
|
||||
{!isSettingHidden('emailsPerPage') && (
|
||||
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
|
||||
<Select
|
||||
@@ -528,75 +320,6 @@ export function EmailSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Always Light Mode for Emails */}
|
||||
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
|
||||
<ToggleSwitch
|
||||
checked={emailAlwaysLightMode}
|
||||
onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* External Content */}
|
||||
{!isSettingHidden('externalContentPolicy') && (
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
onChange={(value) =>
|
||||
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
|
||||
}
|
||||
options={[
|
||||
{ value: 'ask', label: t('external_content.ask') },
|
||||
{ value: 'block', label: t('external_content.block') },
|
||||
{ value: 'allow', label: t('external_content.allow') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Default Mail Program */}
|
||||
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleSetDefaultMailProgram}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
|
||||
</button>
|
||||
{defaultMailStatus === 'success' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
|
||||
)}
|
||||
{defaultMailStatus === 'error' && (
|
||||
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders */}
|
||||
<SettingItem label={t('trusted_senders.label')} description={t('trusted_senders.description')}>
|
||||
<button
|
||||
onClick={() => setShowTrustedModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<span className="text-sm text-foreground">{getTrustedSendersCount()}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders - address book storage */}
|
||||
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||
<ToggleSwitch
|
||||
checked={trustedSendersAddressBook}
|
||||
onChange={(checked) => updateSetting('trustedSendersAddressBook', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders Modal */}
|
||||
<TrustedSendersModal
|
||||
isOpen={showTrustedModal}
|
||||
onClose={() => setShowTrustedModal(false)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
@@ -104,19 +103,6 @@ export function VacationSettings() {
|
||||
textBody: localTextBody,
|
||||
});
|
||||
|
||||
// Re-save the filter script to preserve metadata and include vacation block.
|
||||
// This prevents the server from injecting vacation Sieve code that destroys
|
||||
// the metadata comment the visual filter builder relies on.
|
||||
try {
|
||||
await useFilterStore.getState().syncVacationToScript(client, {
|
||||
isEnabled: localEnabled,
|
||||
subject: localSubject,
|
||||
textBody: localTextBody,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical: vacation was saved via JMAP, script sync is best-effort
|
||||
}
|
||||
|
||||
toast.success(tNotifications('vacation_saved'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save vacation response:', error);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { getMailboxPath } from "@/lib/utils";
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
@@ -30,7 +31,7 @@ interface UseMailboxDropReturn {
|
||||
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
|
||||
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, mailboxes } = useEmailStore();
|
||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||
|
||||
// Determine if this is a valid drop target
|
||||
@@ -106,13 +107,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
|
||||
const emailIds: string[] = JSON.parse(emailIdsJson);
|
||||
|
||||
// Get the destination mailbox ID (use originalId for shared folders)
|
||||
const destinationId = mailbox.originalId || mailbox.id;
|
||||
|
||||
// Move emails one by one (store handles counter updates)
|
||||
for (const emailId of emailIds) {
|
||||
await moveToMailbox(client, emailId, destinationId);
|
||||
}
|
||||
// Move in a single bulk JMAP request (store handles counter updates).
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
|
||||
// Clear selection if any selected emails were moved
|
||||
if (emailIds.some(id => selectedEmailIds.has(id))) {
|
||||
@@ -122,15 +118,15 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Refresh the current mailbox view
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
|
||||
// Call success callback if provided, otherwise use fallback
|
||||
const mailboxPath = getMailboxPath(mailbox, mailboxes);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(emailIds.length, mailbox.name);
|
||||
onSuccess(emailIds.length, mailboxPath);
|
||||
} else {
|
||||
// Fallback for backward compatibility
|
||||
if (emailIds.length === 1) {
|
||||
toast.success("Email moved", `Moved to ${mailbox.name}`);
|
||||
toast.success("Email moved", `Moved to ${mailboxPath}`);
|
||||
} else {
|
||||
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
|
||||
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
|
||||
|
||||
const valid = isValidTarget();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
|
||||
// Tailwind v4 breakpoints
|
||||
@@ -12,26 +12,30 @@ const BREAKPOINTS = {
|
||||
"2xl": 1536,
|
||||
} as const;
|
||||
|
||||
const getMediaQueryServerSnapshot = () => false;
|
||||
|
||||
/**
|
||||
* SSR-safe media query hook
|
||||
* Returns false during SSR to prevent hydration mismatch
|
||||
* SSR-safe media query hook. On SSR and the first hydration pass we report
|
||||
* `false`; on all subsequent client renders (including client-side navigation
|
||||
* remounts) we read `matchMedia` synchronously, so components don't flash
|
||||
* through a one-frame "mobile" layout on desktop.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => {
|
||||
const mq = window.matchMedia(query);
|
||||
mq.addEventListener("change", callback);
|
||||
return () => mq.removeEventListener("change", callback);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
setMatches(mediaQuery.matches);
|
||||
const getSnapshot = useCallback(
|
||||
() => window.matchMedia(query).matches,
|
||||
[query],
|
||||
);
|
||||
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface DragCreateState {
|
||||
@@ -12,9 +13,13 @@ interface DragCreateState {
|
||||
endMinutes: number;
|
||||
}
|
||||
|
||||
export type ResizeEdge = "top" | "bottom";
|
||||
|
||||
interface ResizeState {
|
||||
eventId: string;
|
||||
topPx: number;
|
||||
heightPx: number;
|
||||
startMinutes: number;
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
@@ -40,6 +45,7 @@ interface UseTimeGridInteractionsOptions {
|
||||
created: string;
|
||||
error: string;
|
||||
};
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function useTimeGridInteractions({
|
||||
@@ -47,6 +53,7 @@ export function useTimeGridInteractions({
|
||||
calendars,
|
||||
onCreateRange,
|
||||
errorMessages,
|
||||
isMobile,
|
||||
}: UseTimeGridInteractionsOptions) {
|
||||
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
|
||||
const raw = ((clientY - containerTop) / hourHeight) * 60;
|
||||
@@ -72,6 +79,7 @@ export function useTimeGridInteractions({
|
||||
dayKey: string,
|
||||
dayDate: Date,
|
||||
) => {
|
||||
if (isMobile) return;
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
|
||||
|
||||
@@ -82,7 +90,7 @@ export function useTimeGridInteractions({
|
||||
dayKey, dayDate, startMinutes: minutes,
|
||||
pointerId: e.pointerId, startY: e.clientY, captured: false,
|
||||
};
|
||||
}, [snapToMinutes]);
|
||||
}, [snapToMinutes, isMobile]);
|
||||
|
||||
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
@@ -130,43 +138,65 @@ export function useTimeGridInteractions({
|
||||
// --- Resize ---
|
||||
const resizeRef = useRef<{
|
||||
eventId: string;
|
||||
edge: ResizeEdge;
|
||||
startY: number;
|
||||
originalStartMinutes: number;
|
||||
originalDurationMinutes: number;
|
||||
originalHeightPx: number;
|
||||
pointerId: number;
|
||||
} | null>(null);
|
||||
|
||||
const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null);
|
||||
|
||||
const computeResize = useCallback((
|
||||
ref: NonNullable<typeof resizeRef.current>,
|
||||
clientY: number,
|
||||
): { startMinutes: number; durationMinutes: number } => {
|
||||
const deltaY = clientY - ref.startY;
|
||||
const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15;
|
||||
const originalEnd = ref.originalStartMinutes + ref.originalDurationMinutes;
|
||||
|
||||
if (ref.edge === "bottom") {
|
||||
const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes);
|
||||
return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration };
|
||||
}
|
||||
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0.
|
||||
let newStart = ref.originalStartMinutes + deltaMinutes;
|
||||
newStart = Math.max(0, Math.min(originalEnd - 15, newStart));
|
||||
return { startMinutes: newStart, durationMinutes: originalEnd - newStart };
|
||||
}, [hourHeight]);
|
||||
|
||||
const handleResizePointerDown = useCallback((
|
||||
eventId: string,
|
||||
edge: ResizeEdge,
|
||||
originalStartMinutes: number,
|
||||
originalDurationMinutes: number,
|
||||
e: PointerEvent,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
|
||||
resizeRef.current = {
|
||||
eventId,
|
||||
edge,
|
||||
startY: e.clientY,
|
||||
originalStartMinutes,
|
||||
originalDurationMinutes,
|
||||
originalHeightPx,
|
||||
pointerId: e.pointerId,
|
||||
};
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [hourHeight]);
|
||||
}, []);
|
||||
|
||||
const handleResizePointerMove = useCallback((e: PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
|
||||
const deltaY = e.clientY - resizeRef.current.startY;
|
||||
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY);
|
||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||
const snappedHeight = (newDurationMinutes / 60) * hourHeight;
|
||||
|
||||
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes });
|
||||
}, [hourHeight]);
|
||||
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
|
||||
setResizeVisual({
|
||||
eventId: resizeRef.current.eventId,
|
||||
topPx: (startMinutes / 60) * hourHeight,
|
||||
heightPx: (durationMinutes / 60) * hourHeight,
|
||||
startMinutes,
|
||||
durationMinutes,
|
||||
});
|
||||
}, [hourHeight, computeResize]);
|
||||
|
||||
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
|
||||
const resize = resizeRef.current;
|
||||
@@ -179,11 +209,11 @@ export function useTimeGridInteractions({
|
||||
|
||||
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
|
||||
|
||||
const deltaY = e.clientY - resize.startY;
|
||||
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
|
||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||
const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
|
||||
|
||||
if (newDurationMinutes === resize.originalDurationMinutes) {
|
||||
const startChanged = newStartMinutes !== resize.originalStartMinutes;
|
||||
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
|
||||
if (!startChanged && !durationChanged) {
|
||||
setResizeVisual(null);
|
||||
return;
|
||||
}
|
||||
@@ -205,14 +235,21 @@ export function useTimeGridInteractions({
|
||||
try {
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined);
|
||||
const updates: { start?: string; duration: string } = { duration: dur };
|
||||
if (startChanged && event?.start) {
|
||||
// Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone).
|
||||
const deltaMinutes = newStartMinutes - resize.originalStartMinutes;
|
||||
const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000);
|
||||
updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
}
|
||||
await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined);
|
||||
} catch (error) {
|
||||
debug.error("Failed to resize event:", resize.eventId, error);
|
||||
toast.error(errorMessages.resize);
|
||||
} finally {
|
||||
setResizeVisual(null);
|
||||
}
|
||||
}, [hourHeight, errorMessages.resize]);
|
||||
}, [computeResize, errorMessages.resize]);
|
||||
|
||||
// --- Click / Double-click / Quick-create ---
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -317,14 +354,18 @@ export function useTimeGridInteractions({
|
||||
const minutes = snapDragMinutes(e);
|
||||
const newStart = new Date(day);
|
||||
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
|
||||
// Emit `start` as a floating wall-clock in the event's own timeZone.
|
||||
// If we used browser-local, the server would reinterpret it in event.timeZone
|
||||
// and shift the event by the offset between the two zones.
|
||||
const eventTimeZone = event?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const newStartISO = formatIsoInTimeZone(newStart, eventTimeZone);
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) {
|
||||
toast.error(errorMessages.move);
|
||||
return;
|
||||
}
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth/active-account-slot', () => ({
|
||||
getActiveAccountSlotHeaders: vi.fn(() => ({ 'X-JMAP-Cookie-Slot': '0' })),
|
||||
}));
|
||||
|
||||
import { stalwartJmap, requireResult, STALWART_JMAP_USING } from '@/lib/stalwart/jmap-passthrough';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('stalwartJmap', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('POSTs to /api/account/stalwart/jmap with the standard using array', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
|
||||
|
||||
await stalwartJmap([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
|
||||
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockedFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/account/stalwart/jmap');
|
||||
expect(init.method).toBe('POST');
|
||||
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.using).toEqual(STALWART_JMAP_USING);
|
||||
expect(body.methodCalls).toEqual([['x:Account/get', { accountId: 'a', ids: ['a'] }, '0']]);
|
||||
});
|
||||
|
||||
it('forwards the active account slot header', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: [] }));
|
||||
|
||||
await stalwartJmap([['x:Account/get', {}, '0']]);
|
||||
|
||||
const init = mockedFetch.mock.calls[0][1];
|
||||
expect(init.headers['X-JMAP-Cookie-Slot']).toBe('0');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('returns methodResponses on success', async () => {
|
||||
const responses = [['x:AccountPassword/get', { list: [{ id: 'singleton' }] }, '0']];
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { methodResponses: responses }));
|
||||
|
||||
const result = await stalwartJmap([['x:AccountPassword/get', { accountId: 'a', ids: ['singleton'] }, '0']]);
|
||||
|
||||
expect(result).toEqual(responses);
|
||||
});
|
||||
|
||||
it('throws with status and message when the passthrough returns non-OK', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'Not authenticated' }));
|
||||
|
||||
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: 'Not authenticated',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with HTTP fallback message when error body is unparseable', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(new Response('oh no', { status: 500 }));
|
||||
|
||||
await expect(stalwartJmap([['x:Account/get', {}, '0']])).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: 'HTTP 500',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when first method response is a JMAP-level error', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
|
||||
methodResponses: [['error', { type: 'forbidden', description: 'Current secret must be provided' }, '0']],
|
||||
}));
|
||||
|
||||
await expect(stalwartJmap([['x:AccountPassword/set', {}, '0']])).rejects.toMatchObject({
|
||||
status: 200,
|
||||
message: 'Current secret must be provided',
|
||||
methodError: { type: 'forbidden', description: 'Current secret must be provided' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to error type when description is absent', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, {
|
||||
methodResponses: [['error', { type: 'unknownMethod' }, '0']],
|
||||
}));
|
||||
|
||||
await expect(stalwartJmap([['x:Nope/get', {}, '0']])).rejects.toMatchObject({
|
||||
methodError: { type: 'unknownMethod' },
|
||||
message: 'unknownMethod',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireResult', () => {
|
||||
it('returns the arguments of the matching method', () => {
|
||||
const responses: Array<[string, Record<string, unknown>, string]> = [
|
||||
['x:Account/get', { list: [{ id: 'a' }] }, '0'],
|
||||
['x:AppPassword/query', { ids: ['p1'] }, '1'],
|
||||
];
|
||||
|
||||
const result = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
|
||||
expect(result.ids).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('throws when the expected method is missing', () => {
|
||||
const responses: Array<[string, Record<string, unknown>, string]> = [
|
||||
['x:Account/get', {}, '0'],
|
||||
];
|
||||
|
||||
expect(() => requireResult(responses, 'x:AppPassword/query')).toThrow(/x:AppPassword\/query/);
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { StalwartClient } from '../stalwart/client';
|
||||
|
||||
function mockFetchResponse(status: number, body?: unknown): Response {
|
||||
return new Response(body ? JSON.stringify(body) : null, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('StalwartClient', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let client: StalwartClient;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
client = new StalwartClient('https://mail.example.com/', 'Basic dXNlcjpwYXNz');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('strips trailing slash from server URL', () => {
|
||||
const c = new StalwartClient('https://mail.example.com/', 'Basic abc');
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false, appPasswords: [] } }));
|
||||
c.getAuthInfo();
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/api/account/auth',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns true when server responds with data field', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: { otpEnabled: false } }));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when server returns 401 (API exists but needs auth)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when server returns 404', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(404));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on network error', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('Network error'));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when response has no data field', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { something: 'else' }));
|
||||
const result = await client.probe();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthInfo', () => {
|
||||
it('returns auth info on success', async () => {
|
||||
const authInfo = { otpEnabled: true, isAdminApp: false, appPasswords: ['app1'] };
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: authInfo }));
|
||||
|
||||
const result = await client.getAuthInfo();
|
||||
expect(result).toEqual(authInfo);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/api/account/auth',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Basic dXNlcjpwYXNz',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-ok response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(403, { detail: 'Forbidden' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Forbidden');
|
||||
});
|
||||
|
||||
it('throws with HTTP status when error body is unparseable', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('not json', { status: 500 }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableTotp', () => {
|
||||
it('sends enableOtpAuth action and returns TOTP URL', async () => {
|
||||
const totpUrl = 'otpauth://totp/user@example.com?secret=ABC123';
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: totpUrl }));
|
||||
|
||||
const result = await client.enableTotp();
|
||||
expect(result).toBe(totpUrl);
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'enableOtpAuth' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableTotp', () => {
|
||||
it('sends disableOtpAuth action', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.disableTotp();
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'disableOtpAuth' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAppPassword', () => {
|
||||
it('sends addAppPassword action with name and password', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.addAppPassword('Thunderbird', 'secret123');
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'addAppPassword', name: 'Thunderbird', password: 'secret123' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAppPassword', () => {
|
||||
it('sends removeAppPassword action with name', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.removeAppPassword('Thunderbird');
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual([{ type: 'removeAppPassword', name: 'Thunderbird' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCryptoInfo', () => {
|
||||
it('returns crypto info on success', async () => {
|
||||
const cryptoInfo = { type: 'pgp' as const };
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: cryptoInfo }));
|
||||
|
||||
const result = await client.getCryptoInfo();
|
||||
expect(result).toEqual(cryptoInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCrypto', () => {
|
||||
it('sends crypto settings', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.updateCrypto({ type: 'pgp' });
|
||||
|
||||
const callBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(callBody).toEqual({ type: 'pgp' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrincipal', () => {
|
||||
it('returns principal data on success', async () => {
|
||||
const principal = {
|
||||
id: 1, type: 'individual', name: 'testuser',
|
||||
description: 'Test User', emails: ['test@example.com'],
|
||||
secrets: [], quota: 1000000, roles: ['user'], lists: [],
|
||||
};
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: principal }));
|
||||
|
||||
const result = await client.getPrincipal('testuser');
|
||||
expect(result).toEqual(principal);
|
||||
});
|
||||
|
||||
it('encodes special characters in username', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: {} }));
|
||||
|
||||
await client.getPrincipal('user@example.com');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://mail.example.com/api/principal/user%40example.com',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePrincipal', () => {
|
||||
it('sends PATCH with action array', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.updatePrincipal('testuser', [
|
||||
{ action: 'set', field: 'description', value: 'New Name' },
|
||||
]);
|
||||
|
||||
const call = fetchSpy.mock.calls[0];
|
||||
expect(call[0]).toBe('https://mail.example.com/api/principal/testuser');
|
||||
expect(call[1]?.method).toBe('PATCH');
|
||||
const body = JSON.parse(call[1]?.body as string);
|
||||
expect(body).toEqual([{ action: 'set', field: 'description', value: 'New Name' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('sends set secrets action via updatePrincipal', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.changePassword('testuser', 'newPassword123');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ action: 'set', field: 'secrets', value: 'newPassword123' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDisplayName', () => {
|
||||
it('sends set description action via updatePrincipal', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, { data: null }));
|
||||
|
||||
await client.updateDisplayName('testuser', 'John Doe');
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
||||
expect(body).toEqual([{ action: 'set', field: 'description', value: 'John Doe' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request error handling', () => {
|
||||
it('parses error.detail from response body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { detail: 'Invalid request format' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Invalid request format');
|
||||
});
|
||||
|
||||
it('parses error.details from response body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { details: 'Bad stuff' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Bad stuff');
|
||||
});
|
||||
|
||||
it('parses error.error from response body', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(400, { error: 'Something wrong' }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('Something wrong');
|
||||
});
|
||||
|
||||
it('falls back to HTTP status code on non-JSON error', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('plain text', { status: 502 }));
|
||||
await expect(client.getAuthInfo()).rejects.toThrow('HTTP 502');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -110,7 +110,6 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: '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 },
|
||||
stalwartApiUrl: { envVar: 'STALWART_API_URL', type: 'url', defaultValue: '' },
|
||||
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
||||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||||
|
||||
@@ -13,7 +13,7 @@ function foldLine(line: string): string {
|
||||
return chunks.join("\r\n");
|
||||
}
|
||||
|
||||
// RFC 5545 §3.3.11 — escape backslash, semicolon, comma, and newline in TEXT values.
|
||||
// RFC 5545 §3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values.
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
|
||||
hasAccountCapability(_capability: string, _accountId?: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getCapabilities(): Record<string, unknown> {
|
||||
return {
|
||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||
@@ -265,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
if (mode === 'single') {
|
||||
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId);
|
||||
return;
|
||||
}
|
||||
for (const { id, receivedAt } of emails) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (!email) continue;
|
||||
const d = new Date(receivedAt);
|
||||
const year = d.getFullYear().toString();
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId);
|
||||
if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId);
|
||||
let destId = yearBox.id;
|
||||
if (mode === 'month') {
|
||||
let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id);
|
||||
if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id);
|
||||
destId = monthBox.id;
|
||||
}
|
||||
email.mailboxIds = { [destId]: true };
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||
|
||||
@@ -36,6 +36,26 @@ export function sanitizeEmailHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize config for emails rendered inside a sandboxed iframe.
|
||||
* Allows <style> tags because CSS is scoped to the iframe document and
|
||||
* cannot leak into the host app. Scripts are still blocked by the sandbox
|
||||
* attribute (no allow-scripts). Use ONLY for iframe-rendered content –
|
||||
* never for content rendered into the main DOM.
|
||||
*/
|
||||
export const EMAIL_IFRAME_SANITIZE_CONFIG = {
|
||||
...EMAIL_SANITIZE_CONFIG,
|
||||
FORBID_TAGS: EMAIL_SANITIZE_CONFIG.FORBID_TAGS.filter((t) => t !== 'style'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize email HTML for rendering inside a sandboxed iframe.
|
||||
* Preserves <style> tags so the email's own CSS is applied.
|
||||
*/
|
||||
export function sanitizeEmailHtmlForIframe(html: string): string {
|
||||
return DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature with stricter rules
|
||||
* Only allows basic formatting, no external resources
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface IJMAPClient {
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
getCapabilities(): Record<string, unknown>;
|
||||
hasAccountCapability(capability: string, accountId?: string): boolean;
|
||||
getMaxSizeUpload(): number;
|
||||
getMaxCallsInRequest(): number;
|
||||
getMaxObjectsInGet(): number;
|
||||
@@ -84,6 +85,13 @@ export interface IJMAPClient {
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
accountId?: string,
|
||||
): Promise<void>;
|
||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||
emptyMailbox(mailboxId: string): Promise<number>;
|
||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||
|
||||
@@ -1207,6 +1207,113 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
if (mode === 'single') {
|
||||
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId);
|
||||
return;
|
||||
}
|
||||
|
||||
type Dest = { year: string; month?: string };
|
||||
const destFor = new Map<string, Dest>();
|
||||
for (const e of emails) {
|
||||
const d = new Date(e.receivedAt);
|
||||
const year = d.getFullYear().toString();
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
|
||||
}
|
||||
|
||||
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
|
||||
const yearIdFor = new Map<string, string>();
|
||||
const monthIdFor = new Map<string, string>();
|
||||
const createEntries: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
const findExisting = (name: string, parentId: string) =>
|
||||
existingMailboxes.find(m =>
|
||||
m.accountId === targetAccountId &&
|
||||
m.name === name &&
|
||||
(m.parentId === parentId || m.parentId === (parentId.startsWith('#') ? undefined : parentId)),
|
||||
);
|
||||
|
||||
for (const dest of destFor.values()) {
|
||||
if (!yearIdFor.has(dest.year)) {
|
||||
const existing = findExisting(dest.year, archiveMailboxId);
|
||||
if (existing) {
|
||||
yearIdFor.set(dest.year, existing.originalId || existing.id);
|
||||
} else {
|
||||
const cid = `year-${dest.year}`;
|
||||
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
|
||||
yearIdFor.set(dest.year, `#${cid}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'month' && dest.month) {
|
||||
const monthKey = `${dest.year}/${dest.month}`;
|
||||
if (!monthIdFor.has(monthKey)) {
|
||||
const yearRef = yearIdFor.get(dest.year)!;
|
||||
// Only look up existing month folders under real (non-creation-ref) year ids.
|
||||
const existingMonth = yearRef.startsWith('#')
|
||||
? undefined
|
||||
: findExisting(dest.month, yearRef);
|
||||
if (existingMonth) {
|
||||
monthIdFor.set(monthKey, existingMonth.originalId || existingMonth.id);
|
||||
} else {
|
||||
const cid = `month-${dest.year}-${dest.month}`;
|
||||
createEntries[cid] = { name: dest.month, parentId: yearRef };
|
||||
monthIdFor.set(monthKey, `#${cid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||||
for (const [emailId, dest] of destFor.entries()) {
|
||||
const destId = mode === 'month' && dest.month
|
||||
? monthIdFor.get(`${dest.year}/${dest.month}`)!
|
||||
: yearIdFor.get(dest.year)!;
|
||||
updates[emailId] = { mailboxIds: { [destId]: true } };
|
||||
}
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
const hasCreates = Object.keys(createEntries).length > 0;
|
||||
if (hasCreates) {
|
||||
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
|
||||
}
|
||||
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
if (hasCreates) {
|
||||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||||
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
|
||||
const failures = notCreated ? Object.entries(notCreated) : [];
|
||||
if (failures.length > 0) {
|
||||
const [cid, err] = failures[0];
|
||||
const parts = [err.type || 'unknown'];
|
||||
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err.description) parts.push(err.description);
|
||||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const emailIdx = hasCreates ? 1 : 0;
|
||||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||||
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
|
||||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||||
if (emailFailures.length > 0) {
|
||||
const [id, err] = emailFailures[0];
|
||||
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
@@ -1316,7 +1423,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notCreated?.[createId]) {
|
||||
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`);
|
||||
const err = result.notCreated[createId];
|
||||
const details = [err.type || 'unknown error'];
|
||||
if (Array.isArray(err.properties) && err.properties.length > 0) {
|
||||
details.push(`properties=[${err.properties.join(', ')}]`);
|
||||
}
|
||||
if (err.description) details.push(err.description);
|
||||
throw new Error(`Failed to create mailbox: ${details.join(' – ')}`);
|
||||
}
|
||||
|
||||
const created = result?.created?.[createId];
|
||||
@@ -2558,6 +2671,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
return capability in this.capabilities;
|
||||
}
|
||||
|
||||
/** Check whether a capability is present on the primary account. */
|
||||
hasAccountCapability(capability: string, accountId?: string): boolean {
|
||||
const id = accountId || this.accountId;
|
||||
const caps = this.session?.accounts?.[id]?.accountCapabilities;
|
||||
return !!caps && capability in caps;
|
||||
}
|
||||
|
||||
getMaxSizeUpload(): number {
|
||||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined;
|
||||
return coreCapability?.maxSizeUpload || 0;
|
||||
|
||||
@@ -508,7 +508,7 @@ function escapeRegex(s: string): string {
|
||||
/**
|
||||
* Nextcloud Mail wraps its managed filter region with a pair of
|
||||
* `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically
|
||||
* emits two such regions — one enclosing its own `require [...]` line and
|
||||
* emits two such regions - one enclosing its own `require [...]` line and
|
||||
* another enclosing the if-blocks it generates from its `# FILTER: [...]`
|
||||
* JSON comments. Parsing the interior blocks individually loses the outer
|
||||
* markers (causing later rules to fall back to "External") and mis-attaches
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Stalwart Management API Client
|
||||
*
|
||||
* Provides typed access to Stalwart's /api/ endpoints for user self-service:
|
||||
* - Password change (PATCH /principal/{name})
|
||||
* - Display name update (PATCH /principal/{name})
|
||||
* - App passwords (POST /account/auth)
|
||||
* - TOTP 2FA management (POST /account/auth)
|
||||
* - Encryption-at-rest (GET/POST /account/crypto)
|
||||
* - Account auth info (GET /account/auth)
|
||||
*/
|
||||
|
||||
export interface StalwartAuthInfo {
|
||||
otpEnabled: boolean;
|
||||
isAdminApp: boolean;
|
||||
appPasswords: string[];
|
||||
}
|
||||
|
||||
export interface StalwartCryptoInfo {
|
||||
type: 'disabled' | 'pgp' | 'smime';
|
||||
}
|
||||
|
||||
export interface StalwartPrincipal {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emails: string | string[];
|
||||
secrets: string | string[];
|
||||
quota: number;
|
||||
roles: string[];
|
||||
lists: string[];
|
||||
}
|
||||
|
||||
export interface PrincipalUpdateAction {
|
||||
action: 'set' | 'addItem' | 'removeItem';
|
||||
field: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface StalwartApiError {
|
||||
error: string;
|
||||
details: string;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export class StalwartClient {
|
||||
private baseUrl: string;
|
||||
private authHeader: string;
|
||||
|
||||
constructor(serverUrl: string, authHeader: string) {
|
||||
this.baseUrl = serverUrl.replace(/\/$/, '') + '/api';
|
||||
this.authHeader = authHeader;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = `HTTP ${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body.detail) errorDetail = body.detail;
|
||||
else if (body.details) errorDetail = body.details;
|
||||
else if (body.error) errorDetail = body.error;
|
||||
} catch { /* use status code */ }
|
||||
throw new Error(errorDetail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** Probe whether this server exposes Stalwart's management API */
|
||||
async probe(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/account/auth`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': this.authHeader },
|
||||
});
|
||||
if (response.status === 401) return true; // API exists but needs auth
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
return data.data !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /account/auth - Fetch 2FA and app password status */
|
||||
async getAuthInfo(): Promise<StalwartAuthInfo> {
|
||||
const result = await this.request<{ data: StalwartAuthInfo }>('/account/auth');
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** POST /account/auth - Update auth settings (TOTP, app passwords) */
|
||||
async updateAuth(actions: Array<{ type: string; name?: string; password?: string; url?: string }>): Promise<void> {
|
||||
await this.request<{ data: unknown }>('/account/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(actions),
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable TOTP - returns the TOTP URL for QR code generation */
|
||||
async enableTotp(): Promise<string> {
|
||||
const result = await this.request<{ data: string }>('/account/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{ type: 'enableOtpAuth' }]),
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** Disable TOTP */
|
||||
async disableTotp(): Promise<void> {
|
||||
await this.request<{ data: unknown }>('/account/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{ type: 'disableOtpAuth' }]),
|
||||
});
|
||||
}
|
||||
|
||||
/** Add an app password */
|
||||
async addAppPassword(name: string, password: string): Promise<void> {
|
||||
await this.request<{ data: unknown }>('/account/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{ type: 'addAppPassword', name, password }]),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an app password */
|
||||
async removeAppPassword(name: string): Promise<void> {
|
||||
await this.request<{ data: unknown }>('/account/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{ type: 'removeAppPassword', name }]),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /account/crypto - Fetch encryption-at-rest settings */
|
||||
async getCryptoInfo(): Promise<StalwartCryptoInfo> {
|
||||
const result = await this.request<{ data: StalwartCryptoInfo }>('/account/crypto');
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** POST /account/crypto - Update encryption-at-rest settings */
|
||||
async updateCrypto(settings: { type: string; algo?: string; certs?: string }): Promise<void> {
|
||||
await this.request<{ data: unknown }>('/account/crypto', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /principal/{name} - Fetch principal details */
|
||||
async getPrincipal(name: string): Promise<StalwartPrincipal> {
|
||||
const result = await this.request<{ data: StalwartPrincipal }>(`/principal/${encodeURIComponent(name)}`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** PATCH /principal/{name} - Update principal fields */
|
||||
async updatePrincipal(name: string, actions: PrincipalUpdateAction[]): Promise<void> {
|
||||
await this.request<{ data: unknown }>(`/principal/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(actions),
|
||||
});
|
||||
}
|
||||
|
||||
/** Change password via PATCH /principal/{name} */
|
||||
async changePassword(name: string, newPassword: string): Promise<void> {
|
||||
await this.updatePrincipal(name, [
|
||||
{ action: 'set', field: 'secrets', value: newPassword },
|
||||
]);
|
||||
}
|
||||
|
||||
/** Update display name via PATCH /principal/{name} */
|
||||
async updateDisplayName(name: string, displayName: string): Promise<void> {
|
||||
await this.updatePrincipal(name, [
|
||||
{ action: 'set', field: 'description', value: displayName },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
|
||||
export interface StalwartCredentials {
|
||||
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
|
||||
apiUrl: string;
|
||||
/** URL of the JMAP server (for JMAP operations like password verification) */
|
||||
/** URL of the JMAP server (used for JMAP + management method calls) */
|
||||
serverUrl: string;
|
||||
authHeader: string;
|
||||
username: string;
|
||||
@@ -14,26 +12,6 @@ export interface StalwartCredentials {
|
||||
slot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base URL for Stalwart management API requests.
|
||||
*
|
||||
* When the JMAP server sits behind a reverse proxy that only forwards
|
||||
* JMAP paths, the `/api/account/*` and `/api/principal/*` management
|
||||
* endpoints may not be exposed. In that case, operators can set
|
||||
* `STALWART_API_URL` to point directly at the Stalwart HTTP listener
|
||||
* (e.g. `https://admin.example.com`).
|
||||
*/
|
||||
function getStalwartApiUrl(jmapServerUrl: string): string {
|
||||
const url = process.env.STALWART_API_URL || jmapServerUrl;
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract credentials from the incoming request.
|
||||
*
|
||||
* Credentials are read from a verified, httpOnly auth-context cookie that is
|
||||
* populated after a successful JMAP login or token refresh.
|
||||
*/
|
||||
function parseSlot(raw: string | null): number | null {
|
||||
if (raw === null) return null;
|
||||
const slot = parseInt(raw, 10);
|
||||
@@ -55,8 +33,7 @@ export async function getStalwartCredentials(request: NextRequest): Promise<Stal
|
||||
if (!context) continue;
|
||||
|
||||
return {
|
||||
apiUrl: getStalwartApiUrl(context.serverUrl),
|
||||
serverUrl: context.serverUrl,
|
||||
serverUrl: context.serverUrl.replace(/\/+$/, ''),
|
||||
authHeader: context.authHeader,
|
||||
username: context.username,
|
||||
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
export type JmapMethodCall = [string, Record<string, unknown>, string];
|
||||
export type JmapMethodResponse = [string, Record<string, unknown>, string];
|
||||
|
||||
export const STALWART_JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
|
||||
|
||||
export interface StalwartJmapError extends Error {
|
||||
status: number;
|
||||
methodError?: { type: string; description?: string };
|
||||
}
|
||||
|
||||
function buildError(message: string, status: number, methodError?: StalwartJmapError['methodError']): StalwartJmapError {
|
||||
const err = new Error(message) as StalwartJmapError;
|
||||
err.status = status;
|
||||
if (methodError) err.methodError = methodError;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JMAP request to Stalwart via the server-side passthrough.
|
||||
* The passthrough injects the stored basic-auth header so credentials
|
||||
* stay in an httpOnly cookie.
|
||||
*/
|
||||
export async function stalwartJmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
|
||||
const response = await apiFetch('/api/account/stalwart/jmap', {
|
||||
method: 'POST',
|
||||
headers: { ...getActiveAccountSlotHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using: STALWART_JMAP_USING, methodCalls }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body?.error) message = body.error;
|
||||
} catch { /* ignore */ }
|
||||
throw buildError(message, response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responses = (data.methodResponses ?? []) as JmapMethodResponse[];
|
||||
|
||||
const first = responses[0];
|
||||
if (first && first[0] === 'error') {
|
||||
const result = first[1] as { type?: string; description?: string };
|
||||
throw buildError(result.description || result.type || 'JMAP error', 200, {
|
||||
type: result.type || 'unknown',
|
||||
description: result.description,
|
||||
});
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
export function requireResult<T = Record<string, unknown>>(
|
||||
responses: JmapMethodResponse[],
|
||||
expectedMethod: string,
|
||||
): T {
|
||||
const match = responses.find(r => r[0] === expectedMethod);
|
||||
if (!match) {
|
||||
throw buildError(`Expected method ${expectedMethod} in response`, 200);
|
||||
}
|
||||
return match[1] as T;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Ordner",
|
||||
"mail": "E-Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
"add_app": "Apps",
|
||||
"shared": "Geteilt"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sidebar-Apps",
|
||||
@@ -670,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",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Gemeinsames Postfach",
|
||||
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Farbige Seitenleistensymbole",
|
||||
"description": "Ordner- und Tag-Symbole nach Typ einfärben (blauer Posteingang, roter Spam, grüner Gesendet usw.). Für eine monochrome Seitenleiste deaktivieren."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert",
|
||||
"enable_error": "2FA konnte nicht aktiviert werden",
|
||||
"disable_error": "2FA konnte nicht deaktiviert werden",
|
||||
"setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):"
|
||||
"setup_instructions": "Kopieren Sie diese URL in Ihre Authenticator-App (Google Authenticator, Authy, etc.):",
|
||||
"verification_code": "Verifizierungscode",
|
||||
"confirm": "Bestätigen",
|
||||
"disable": "Deaktivieren",
|
||||
"disable_confirm_prompt": "Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren.",
|
||||
"password_required": "Passwort ist erforderlich",
|
||||
"code_required": "Verifizierungscode ist erforderlich",
|
||||
"code_invalid": "Ungültiger Verifizierungscode. Überprüfen Sie Ihre Authenticator-App und versuchen Sie es erneut."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "App-Passwörter",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "App-Passwort entfernt",
|
||||
"add_error": "App-Passwort konnte nicht erstellt werden",
|
||||
"remove_error": "App-Passwort konnte nicht entfernt werden",
|
||||
"none": "Keine App-Passwörter konfiguriert"
|
||||
"none": "Keine App-Passwörter konfiguriert",
|
||||
"done": "Fertig",
|
||||
"expires_label": "Läuft ab (optional)",
|
||||
"copy_now_warning": "Kopieren Sie dieses Passwort jetzt - es wird nicht erneut angezeigt.",
|
||||
"allowed_ips_label": "Erlaubte IPs (optional)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Komma- oder leerzeichengetrennt. Leer lassen, um jede IP zuzulassen."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API-Schlüssel",
|
||||
"description": "Erstellen Sie API-Schlüssel für Skripte und Integrationen, die direkt mit dem Server kommunizieren",
|
||||
"name_label": "Schlüsselname",
|
||||
"name_placeholder": "z.B. Backup-Skript, CI-Runner",
|
||||
"copy_now_warning": "Kopieren Sie diesen API-Schlüssel jetzt - er wird nicht erneut angezeigt.",
|
||||
"added": "API-Schlüssel erstellt",
|
||||
"removed": "API-Schlüssel entfernt",
|
||||
"add_error": "API-Schlüssel konnte nicht erstellt werden",
|
||||
"remove_error": "API-Schlüssel konnte nicht entfernt werden",
|
||||
"none": "Keine API-Schlüssel konfiguriert"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Verschlüsselung im Ruhezustand",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# Bedingung} other {# Bedingungen}}",
|
||||
"actions_count": "{count, plural, one {# Aktion} other {# Aktionen}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Extern",
|
||||
"managed_by_tooltip": "Verwaltet von {source}. Bearbeiten Sie sie in dieser App oder verwenden Sie den rohen Sieve-Editor."
|
||||
},
|
||||
"templates": {
|
||||
"title": "E-Mail-Vorlagen",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
|
||||
"empty_search": "Keine Kontakte gefunden",
|
||||
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
|
||||
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
|
||||
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
|
||||
"clear_search": "Suche löschen",
|
||||
"import_vcard": "vCard importieren",
|
||||
"delete_confirm_title": "Kontakt löschen",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"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",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Neuer Kontakt",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"email_error_inline": "Ungültiges E-Mail-Format",
|
||||
"save_failed": "Kontakt konnte nicht gespeichert werden",
|
||||
"delete": "Löschen"
|
||||
"delete": "Löschen",
|
||||
"upload_photo": "Foto hochladen",
|
||||
"remove_photo": "Foto entfernen",
|
||||
"photo_hint": "JPG oder PNG, bis zu 10 MB. Wird verkleinert.",
|
||||
"photo_too_large": "Bild ist zu groß (max. 10 MB)",
|
||||
"photo_invalid": "Ungültige Bilddatei",
|
||||
"change_photo": "Ändern"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Neue Gruppe",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Kontakt konnte nicht erstellt werden",
|
||||
"error_update": "Kontakt konnte nicht aktualisiert werden",
|
||||
"error_delete": "Kontakt konnte nicht gelöscht werden"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Öffnen",
|
||||
"edit": "Bearbeiten",
|
||||
"send_email": "E-Mail senden",
|
||||
"add_to_group": "Zur Gruppe hinzufügen",
|
||||
"export_vcard": "Als vCard exportieren",
|
||||
"delete": "Löschen",
|
||||
"call": "Anrufen",
|
||||
"duplicate": "Duplizieren",
|
||||
"print": "Drucken"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filter",
|
||||
"select": "Auswählen",
|
||||
"clear": "Zurücksetzen",
|
||||
"close": "Schließen",
|
||||
"title": "Erweiterte Filter",
|
||||
"organization": "Firma",
|
||||
"organization_placeholder": "z. B. Acme GmbH",
|
||||
"job_title": "Berufsbezeichnung",
|
||||
"job_title_placeholder": "z. B. Designer",
|
||||
"location": "Ort",
|
||||
"location_placeholder": "Stadt oder Land",
|
||||
"email_domain": "E-Mail-Domain",
|
||||
"email_domain_placeholder": "beispiel.de",
|
||||
"birthday_month": "Geburtstag im",
|
||||
"any_month": "Beliebiger Monat",
|
||||
"has_email": "Mit E-Mail",
|
||||
"has_phone": "Mit Telefon",
|
||||
"has_photo": "Mit Foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Vorläufig",
|
||||
"needs_action": "Antwort ausstehend",
|
||||
"remove": "Entfernen",
|
||||
"edit": "Bearbeiten",
|
||||
"email_placeholder": "E-Mail-Adresse hinzufügen oder Kontakte durchsuchen",
|
||||
"send_invitations": "Einladungen an Teilnehmer senden",
|
||||
"status_summary": "{accepted} zugesagt, {pending} ausstehend",
|
||||
|
||||
@@ -426,7 +426,9 @@
|
||||
"cancel_info": "The organizer has cancelled this event.",
|
||||
"event_updated": "Update #{sequence}",
|
||||
"event_status_tentative": "Tentative",
|
||||
"event_status_cancelled": "Cancelled"
|
||||
"event_status_cancelled": "Cancelled",
|
||||
"expand": "Show details",
|
||||
"collapse": "Hide details"
|
||||
},
|
||||
"send": "Send",
|
||||
"more": "more"
|
||||
@@ -671,14 +673,24 @@
|
||||
"contacts": "Contacts",
|
||||
"encryption": "Encryption",
|
||||
"sidebar_apps": "Sidebar Apps",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"layout": "Layout",
|
||||
"reading": "Reading",
|
||||
"composing": "Composing",
|
||||
"content_senders": "Content & Senders",
|
||||
"about_data": "About & Data",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "General",
|
||||
"account": "Account & Identity",
|
||||
"organization": "Mail Organization",
|
||||
"apps": "Apps",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"appearance": "Appearance",
|
||||
"mail": "Mail",
|
||||
"privacy": "Privacy & Security",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -1080,7 +1092,14 @@
|
||||
"disabled": "Two-factor authentication disabled",
|
||||
"enable_error": "Failed to enable 2FA",
|
||||
"disable_error": "Failed to disable 2FA",
|
||||
"setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):"
|
||||
"setup_instructions": "Copy this URL into your authenticator app (Google Authenticator, Authy, etc.):",
|
||||
"verification_code": "Verification code",
|
||||
"confirm": "Confirm",
|
||||
"disable": "Disable",
|
||||
"disable_confirm_prompt": "Enter your password to disable two-factor authentication.",
|
||||
"password_required": "Password is required",
|
||||
"code_required": "Verification code is required",
|
||||
"code_invalid": "Invalid verification code. Check your authenticator app and try again."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "App Passwords",
|
||||
@@ -1088,17 +1107,35 @@
|
||||
"add": "Add",
|
||||
"create": "Create",
|
||||
"cancel": "Cancel",
|
||||
"done": "Done",
|
||||
"generate": "Generate",
|
||||
"name_label": "App Name",
|
||||
"name_placeholder": "e.g. Thunderbird, iPhone Mail",
|
||||
"expires_label": "Expires (optional)",
|
||||
"allowed_ips_label": "Allowed IPs (optional)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Comma- or space-separated. Leave empty to allow any IP.",
|
||||
"password_label": "Password (leave empty to auto-generate)",
|
||||
"password_placeholder": "Auto-generated if empty",
|
||||
"copy_now_warning": "Copy this password now - it will not be shown again.",
|
||||
"added": "App password created",
|
||||
"removed": "App password removed",
|
||||
"add_error": "Failed to create app password",
|
||||
"remove_error": "Failed to remove app password",
|
||||
"none": "No app passwords configured"
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API Keys",
|
||||
"description": "Create API keys for scripts and integrations that talk to the server directly",
|
||||
"name_label": "Key Name",
|
||||
"name_placeholder": "e.g. Backup script, CI runner",
|
||||
"copy_now_warning": "Copy this API key now - it will not be shown again.",
|
||||
"added": "API key created",
|
||||
"removed": "API key removed",
|
||||
"add_error": "Failed to create API key",
|
||||
"remove_error": "Failed to remove API key",
|
||||
"none": "No API keys configured"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Encryption at Rest",
|
||||
"label": "Email Encryption",
|
||||
@@ -1285,6 +1322,8 @@
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"description": "Import and export your contacts",
|
||||
"group_by_letter_label": "Group by first letter",
|
||||
"group_by_letter_description": "Show alphabetical section headers in the contact list",
|
||||
"import_label": "Import Contacts",
|
||||
"import_description": "Import contacts from a vCard (.vcf) file",
|
||||
"export_label": "Export Contacts",
|
||||
@@ -1712,6 +1751,8 @@
|
||||
"empty_state_subtitle": "Create your first contact or import from a vCard file",
|
||||
"empty_search": "No contacts match your search",
|
||||
"empty_search_hint": "Try a different search term",
|
||||
"empty_filtered": "No contacts match your filters",
|
||||
"empty_filtered_hint": "Try adjusting or clearing filters",
|
||||
"clear_search": "Clear search",
|
||||
"import_vcard": "Import vCard",
|
||||
"delete_confirm_title": "Delete contact",
|
||||
@@ -1789,7 +1830,33 @@
|
||||
"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",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "New Contact",
|
||||
@@ -1885,7 +1952,13 @@
|
||||
"email_invalid": "Please enter a valid email address",
|
||||
"email_error_inline": "Invalid email format",
|
||||
"save_failed": "Failed to save contact",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"upload_photo": "Upload photo",
|
||||
"remove_photo": "Remove photo",
|
||||
"photo_hint": "JPG or PNG, up to 10 MB. Will be resized.",
|
||||
"photo_too_large": "Image is too large (max 10 MB)",
|
||||
"photo_invalid": "Invalid image file",
|
||||
"change_photo": "Change"
|
||||
},
|
||||
"groups": {
|
||||
"create": "New Group",
|
||||
@@ -1945,6 +2018,37 @@
|
||||
"error_create": "Failed to create contact",
|
||||
"error_update": "Failed to update contact",
|
||||
"error_delete": "Failed to delete contact"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Open",
|
||||
"edit": "Edit",
|
||||
"send_email": "Send email",
|
||||
"add_to_group": "Add to group",
|
||||
"export_vcard": "Export as vCard",
|
||||
"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": {
|
||||
@@ -2020,6 +2124,7 @@
|
||||
"tentative": "Tentative",
|
||||
"needs_action": "Needs action",
|
||||
"remove": "Remove",
|
||||
"edit": "Edit",
|
||||
"email_placeholder": "Add email address or search contacts",
|
||||
"send_invitations": "Send invitations to participants",
|
||||
"status_summary": "{accepted} accepted, {pending} pending",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Carpetas",
|
||||
"mail": "Correo",
|
||||
"nav_label": "Navegación",
|
||||
"add_app": "Apps"
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartido"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplicaciones de la barra lateral",
|
||||
@@ -670,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",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Buzón unificado",
|
||||
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Iconos de barra lateral a color",
|
||||
"description": "Colorea los iconos de carpetas y etiquetas según su tipo (azul para Bandeja de entrada, rojo para Spam, verde para Enviados, etc.). Desactívalo para una barra lateral monocroma."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Autenticación de dos factores deshabilitada",
|
||||
"enable_error": "No se pudo habilitar 2FA",
|
||||
"disable_error": "No se pudo deshabilitar 2FA",
|
||||
"setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):"
|
||||
"setup_instructions": "Copie esta URL en su aplicación de autenticación (Google Authenticator, Authy, etc.):",
|
||||
"verification_code": "Código de verificación",
|
||||
"confirm": "Confirmar",
|
||||
"disable": "Desactivar",
|
||||
"disable_confirm_prompt": "Introduce tu contraseña para desactivar la autenticación de dos factores.",
|
||||
"password_required": "Se requiere la contraseña",
|
||||
"code_required": "Se requiere el código de verificación",
|
||||
"code_invalid": "Código de verificación no válido. Revisa tu aplicación de autenticación e inténtalo de nuevo."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Contraseñas de aplicación",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Contraseña de aplicación eliminada",
|
||||
"add_error": "No se pudo crear la contraseña de aplicación",
|
||||
"remove_error": "No se pudo eliminar la contraseña de aplicación",
|
||||
"none": "No hay contraseñas de aplicación configuradas"
|
||||
"none": "No hay contraseñas de aplicación configuradas",
|
||||
"done": "Hecho",
|
||||
"expires_label": "Caduca (opcional)",
|
||||
"copy_now_warning": "Copia esta contraseña ahora - no se volverá a mostrar.",
|
||||
"allowed_ips_label": "IPs permitidas (opcional)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Separadas por coma o espacio. Dejar vacío para permitir cualquier IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "Claves API",
|
||||
"description": "Crea claves API para scripts e integraciones que se comunican directamente con el servidor",
|
||||
"name_label": "Nombre de la clave",
|
||||
"name_placeholder": "p. ej. Script de copia de seguridad, CI runner",
|
||||
"copy_now_warning": "Copia esta clave API ahora - no se mostrará de nuevo.",
|
||||
"added": "Clave API creada",
|
||||
"removed": "Clave API eliminada",
|
||||
"add_error": "No se pudo crear la clave API",
|
||||
"remove_error": "No se pudo eliminar la clave API",
|
||||
"none": "No hay claves API configuradas"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Cifrado en reposo",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# condición} other {# condiciones}}",
|
||||
"actions_count": "{count, plural, one {# acción} other {# acciones}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Externo",
|
||||
"managed_by_tooltip": "Gestionado por {source}. Edítalo en esa aplicación o usa el editor Sieve sin procesar."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Plantillas de correo",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
|
||||
"empty_search": "Ningún contacto coincide con tu búsqueda",
|
||||
"empty_search_hint": "Prueba con otro término de búsqueda",
|
||||
"empty_filtered": "Ningún contacto coincide con tus filtros",
|
||||
"empty_filtered_hint": "Ajusta o limpia los filtros",
|
||||
"clear_search": "Borrar búsqueda",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Eliminar contacto",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"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",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuevo contacto",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Introduce una dirección de correo válida",
|
||||
"email_error_inline": "Formato de correo inválido",
|
||||
"save_failed": "Error al guardar el contacto",
|
||||
"delete": "Eliminar"
|
||||
"delete": "Eliminar",
|
||||
"upload_photo": "Subir foto",
|
||||
"remove_photo": "Quitar foto",
|
||||
"photo_hint": "JPG o PNG, hasta 10 MB. Se redimensionará.",
|
||||
"photo_too_large": "La imagen es demasiado grande (máx. 10 MB)",
|
||||
"photo_invalid": "Archivo de imagen no válido",
|
||||
"change_photo": "Cambiar"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuevo grupo",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Error al crear el contacto",
|
||||
"error_update": "Error al actualizar el contacto",
|
||||
"error_delete": "Error al eliminar el contacto"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Abrir",
|
||||
"edit": "Editar",
|
||||
"send_email": "Enviar correo",
|
||||
"add_to_group": "Añadir al grupo",
|
||||
"export_vcard": "Exportar como vCard",
|
||||
"delete": "Eliminar",
|
||||
"call": "Llamar",
|
||||
"duplicate": "Duplicar",
|
||||
"print": "Imprimir"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtros",
|
||||
"select": "Seleccionar",
|
||||
"clear": "Borrar",
|
||||
"close": "Cerrar",
|
||||
"title": "Filtros avanzados",
|
||||
"organization": "Empresa",
|
||||
"organization_placeholder": "p. ej. Acme",
|
||||
"job_title": "Puesto",
|
||||
"job_title_placeholder": "p. ej. Diseñador",
|
||||
"location": "Ubicación",
|
||||
"location_placeholder": "Ciudad o país",
|
||||
"email_domain": "Dominio de correo",
|
||||
"email_domain_placeholder": "ejemplo.com",
|
||||
"birthday_month": "Cumpleaños en",
|
||||
"any_month": "Cualquier mes",
|
||||
"has_email": "Con correo",
|
||||
"has_phone": "Con teléfono",
|
||||
"has_photo": "Con foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Provisional",
|
||||
"needs_action": "Pendiente de respuesta",
|
||||
"remove": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"email_placeholder": "Añadir dirección de correo o buscar contactos",
|
||||
"send_invitations": "Enviar invitaciones a los participantes",
|
||||
"status_summary": "{accepted} aceptado(s), {pending} pendiente(s)",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Dossiers",
|
||||
"mail": "Messagerie",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps"
|
||||
"add_app": "Apps",
|
||||
"shared": "Partagé"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Applications de la barre latérale",
|
||||
@@ -670,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",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Boîte aux lettres unifiée",
|
||||
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Icônes colorées dans la barre latérale",
|
||||
"description": "Colore les icônes de dossiers et d'étiquettes par type (Boîte de réception en bleu, Indésirable en rouge, Envoyés en vert, etc.). Désactivez pour une barre latérale monochrome."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Authentification à deux facteurs désactivée",
|
||||
"enable_error": "Impossible d'activer la 2FA",
|
||||
"disable_error": "Impossible de désactiver la 2FA",
|
||||
"setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :"
|
||||
"setup_instructions": "Copiez cette URL dans votre application d'authentification (Google Authenticator, Authy, etc.) :",
|
||||
"verification_code": "Code de vérification",
|
||||
"confirm": "Confirmer",
|
||||
"disable": "Désactiver",
|
||||
"disable_confirm_prompt": "Saisissez votre mot de passe pour désactiver l'authentification à deux facteurs.",
|
||||
"password_required": "Le mot de passe est requis",
|
||||
"code_required": "Le code de vérification est requis",
|
||||
"code_invalid": "Code de vérification non valide. Vérifiez votre application d'authentification et réessayez."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Mots de passe d'application",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Mot de passe d'application supprimé",
|
||||
"add_error": "Impossible de créer le mot de passe d'application",
|
||||
"remove_error": "Impossible de supprimer le mot de passe d'application",
|
||||
"none": "Aucun mot de passe d'application configuré"
|
||||
"none": "Aucun mot de passe d'application configuré",
|
||||
"done": "Terminé",
|
||||
"expires_label": "Expire (facultatif)",
|
||||
"copy_now_warning": "Copiez ce mot de passe maintenant - il ne sera plus affiché.",
|
||||
"allowed_ips_label": "IP autorisées (facultatif)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Séparées par virgule ou espace. Laisser vide pour autoriser toute IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "Clés API",
|
||||
"description": "Créez des clés API pour les scripts et intégrations qui communiquent directement avec le serveur",
|
||||
"name_label": "Nom de la clé",
|
||||
"name_placeholder": "ex. Script de sauvegarde, CI runner",
|
||||
"copy_now_warning": "Copiez cette clé API maintenant - elle ne sera plus affichée.",
|
||||
"added": "Clé API créée",
|
||||
"removed": "Clé API supprimée",
|
||||
"add_error": "Échec de la création de la clé API",
|
||||
"remove_error": "Échec de la suppression de la clé API",
|
||||
"none": "Aucune clé API configurée"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Chiffrement au repos",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# condition} other {# conditions}}",
|
||||
"actions_count": "{count, plural, one {# action} other {# actions}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Externe",
|
||||
"managed_by_tooltip": "Géré par {source}. Modifiez-le dans cette application, ou utilisez l'éditeur Sieve brut."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Modèles d'e-mails",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
|
||||
"empty_search": "Aucun contact ne correspond à votre recherche",
|
||||
"empty_search_hint": "Essayez un autre terme de recherche",
|
||||
"empty_filtered": "Aucun contact ne correspond à vos filtres",
|
||||
"empty_filtered_hint": "Ajustez ou effacez les filtres",
|
||||
"clear_search": "Effacer la recherche",
|
||||
"import_vcard": "Importer vCard",
|
||||
"delete_confirm_title": "Supprimer le contact",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"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",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nouveau contact",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Veuillez saisir une adresse e-mail valide",
|
||||
"email_error_inline": "Format d'e-mail invalide",
|
||||
"save_failed": "Échec de l'enregistrement du contact",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"upload_photo": "Téléverser une photo",
|
||||
"remove_photo": "Supprimer la photo",
|
||||
"photo_hint": "JPG ou PNG, jusqu’à 10 Mo. Sera redimensionnée.",
|
||||
"photo_too_large": "L’image est trop grande (max 10 Mo)",
|
||||
"photo_invalid": "Fichier image invalide",
|
||||
"change_photo": "Modifier"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nouveau groupe",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Échec de la création du contact",
|
||||
"error_update": "Échec de la mise à jour du contact",
|
||||
"error_delete": "Échec de la suppression du contact"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Ouvrir",
|
||||
"edit": "Modifier",
|
||||
"send_email": "Envoyer un courriel",
|
||||
"add_to_group": "Ajouter au groupe",
|
||||
"export_vcard": "Exporter en vCard",
|
||||
"delete": "Supprimer",
|
||||
"call": "Appeler",
|
||||
"duplicate": "Dupliquer",
|
||||
"print": "Imprimer"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtres",
|
||||
"select": "Sélectionner",
|
||||
"clear": "Effacer",
|
||||
"close": "Fermer",
|
||||
"title": "Filtres avancés",
|
||||
"organization": "Entreprise",
|
||||
"organization_placeholder": "ex. Acme",
|
||||
"job_title": "Poste",
|
||||
"job_title_placeholder": "ex. Designer",
|
||||
"location": "Lieu",
|
||||
"location_placeholder": "Ville ou pays",
|
||||
"email_domain": "Domaine e-mail",
|
||||
"email_domain_placeholder": "exemple.com",
|
||||
"birthday_month": "Anniversaire en",
|
||||
"any_month": "Tous les mois",
|
||||
"has_email": "Avec e-mail",
|
||||
"has_phone": "Avec téléphone",
|
||||
"has_photo": "Avec photo"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Provisoire",
|
||||
"needs_action": "En attente de réponse",
|
||||
"remove": "Retirer",
|
||||
"edit": "Modifier",
|
||||
"email_placeholder": "Ajouter une adresse e-mail ou chercher des contacts",
|
||||
"send_invitations": "Envoyer les invitations aux participants",
|
||||
"status_summary": "{accepted} accepté(s), {pending} en attente",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Cartelle",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Navigazione",
|
||||
"add_app": "App"
|
||||
"add_app": "App",
|
||||
"shared": "Condiviso"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "App della barra laterale",
|
||||
@@ -670,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",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Casella di posta unificata",
|
||||
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Icone colorate nella barra laterale",
|
||||
"description": "Colora le icone di cartelle ed etichette per tipo (Posta in arrivo blu, Spam rosso, Inviati verde, ecc.). Disattiva per una barra laterale monocromatica."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Autenticazione a due fattori disabilitata",
|
||||
"enable_error": "Impossibile abilitare la 2FA",
|
||||
"disable_error": "Impossibile disabilitare la 2FA",
|
||||
"setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):"
|
||||
"setup_instructions": "Copia questo URL nella tua app di autenticazione (Google Authenticator, Authy, ecc.):",
|
||||
"verification_code": "Codice di verifica",
|
||||
"confirm": "Conferma",
|
||||
"disable": "Disattiva",
|
||||
"disable_confirm_prompt": "Inserisci la tua password per disattivare l'autenticazione a due fattori.",
|
||||
"password_required": "La password è obbligatoria",
|
||||
"code_required": "Il codice di verifica è obbligatorio",
|
||||
"code_invalid": "Codice di verifica non valido. Controlla la tua app di autenticazione e riprova."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Password per le app",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Password per l'app rimossa",
|
||||
"add_error": "Impossibile creare la password per l'app",
|
||||
"remove_error": "Impossibile rimuovere la password per l'app",
|
||||
"none": "Nessuna password per le app configurata"
|
||||
"none": "Nessuna password per le app configurata",
|
||||
"done": "Fatto",
|
||||
"expires_label": "Scadenza (facoltativa)",
|
||||
"copy_now_warning": "Copia questa password ora - non verrà più mostrata.",
|
||||
"allowed_ips_label": "IP consentiti (opzionale)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Separati da virgola o spazio. Lasciare vuoto per consentire qualsiasi IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "Chiavi API",
|
||||
"description": "Crea chiavi API per script e integrazioni che comunicano direttamente con il server",
|
||||
"name_label": "Nome chiave",
|
||||
"name_placeholder": "es. Script di backup, CI runner",
|
||||
"copy_now_warning": "Copia subito questa chiave API - non verrà mostrata di nuovo.",
|
||||
"added": "Chiave API creata",
|
||||
"removed": "Chiave API rimossa",
|
||||
"add_error": "Impossibile creare la chiave API",
|
||||
"remove_error": "Impossibile rimuovere la chiave API",
|
||||
"none": "Nessuna chiave API configurata"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Crittografia a riposo",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# condizione} other {# condizioni}}",
|
||||
"actions_count": "{count, plural, one {# azione} other {# azioni}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Esterno",
|
||||
"managed_by_tooltip": "Gestito da {source}. Modificalo in quell'app o usa l'editor Sieve grezzo."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Modelli email",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
|
||||
"empty_search": "Nessun contatto corrisponde alla ricerca",
|
||||
"empty_search_hint": "Prova con un altro termine di ricerca",
|
||||
"empty_filtered": "Nessun contatto corrisponde ai filtri",
|
||||
"empty_filtered_hint": "Modifica o cancella i filtri",
|
||||
"clear_search": "Cancella ricerca",
|
||||
"import_vcard": "Importa vCard",
|
||||
"delete_confirm_title": "Elimina contatto",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"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",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuovo contatto",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Inserisci un indirizzo email valido",
|
||||
"email_error_inline": "Formato email non valido",
|
||||
"save_failed": "Impossibile salvare il contatto",
|
||||
"delete": "Elimina"
|
||||
"delete": "Elimina",
|
||||
"upload_photo": "Carica foto",
|
||||
"remove_photo": "Rimuovi foto",
|
||||
"photo_hint": "JPG o PNG, fino a 10 MB. Verrà ridimensionata.",
|
||||
"photo_too_large": "L'immagine è troppo grande (max 10 MB)",
|
||||
"photo_invalid": "File immagine non valido",
|
||||
"change_photo": "Cambia"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuovo gruppo",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Impossibile creare il contatto",
|
||||
"error_update": "Impossibile aggiornare il contatto",
|
||||
"error_delete": "Impossibile eliminare il contatto"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Apri",
|
||||
"edit": "Modifica",
|
||||
"send_email": "Invia email",
|
||||
"add_to_group": "Aggiungi al gruppo",
|
||||
"export_vcard": "Esporta come vCard",
|
||||
"delete": "Elimina",
|
||||
"call": "Chiama",
|
||||
"duplicate": "Duplica",
|
||||
"print": "Stampa"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtri",
|
||||
"select": "Seleziona",
|
||||
"clear": "Azzera",
|
||||
"close": "Chiudi",
|
||||
"title": "Filtri avanzati",
|
||||
"organization": "Azienda",
|
||||
"organization_placeholder": "es. Acme",
|
||||
"job_title": "Ruolo",
|
||||
"job_title_placeholder": "es. Designer",
|
||||
"location": "Luogo",
|
||||
"location_placeholder": "Città o paese",
|
||||
"email_domain": "Dominio email",
|
||||
"email_domain_placeholder": "esempio.com",
|
||||
"birthday_month": "Compleanno in",
|
||||
"any_month": "Qualsiasi mese",
|
||||
"has_email": "Con email",
|
||||
"has_phone": "Con telefono",
|
||||
"has_photo": "Con foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Provvisorio",
|
||||
"needs_action": "In attesa di risposta",
|
||||
"remove": "Rimuovi",
|
||||
"edit": "Modifica",
|
||||
"email_placeholder": "Aggiungi indirizzo email o cerca contatti",
|
||||
"send_invitations": "Invia inviti ai partecipanti",
|
||||
"status_summary": "{accepted} accettato/i, {pending} in attesa",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "フォルダ",
|
||||
"mail": "メール",
|
||||
"nav_label": "ナビゲーション",
|
||||
"add_app": "アプリ"
|
||||
"add_app": "アプリ",
|
||||
"shared": "共有"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "サイドバーアプリ",
|
||||
@@ -670,14 +671,24 @@
|
||||
"files": "ファイル",
|
||||
"contacts": "連絡先",
|
||||
"sidebar_apps": "サイドバーアプリ",
|
||||
"notifications": "通知"
|
||||
"notifications": "通知",
|
||||
"layout": "レイアウト",
|
||||
"reading": "閲覧",
|
||||
"composing": "作成",
|
||||
"content_senders": "コンテンツと送信者",
|
||||
"about_data": "情報とデータ",
|
||||
"debug": "デバッグ"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "一般",
|
||||
"account": "アカウントと身元",
|
||||
"organization": "メール整理",
|
||||
"apps": "アプリ",
|
||||
"system": "システム"
|
||||
"system": "システム",
|
||||
"appearance": "外観",
|
||||
"mail": "メール",
|
||||
"privacy": "プライバシーとセキュリティ",
|
||||
"advanced": "詳細"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外観",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "統合メールボックス",
|
||||
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "カラフルなサイドバーアイコン",
|
||||
"description": "フォルダーとタグのアイコンを種類別に色分けします(受信トレイは青、迷惑メールは赤、送信済みは緑など)。モノクロのサイドバーにするには無効にしてください。"
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "二要素認証が無効になりました",
|
||||
"enable_error": "2FAを有効にできませんでした",
|
||||
"disable_error": "2FAを無効にできませんでした",
|
||||
"setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:"
|
||||
"setup_instructions": "このURLを認証アプリ(Google Authenticator、Authyなど)にコピーしてください:",
|
||||
"verification_code": "確認コード",
|
||||
"confirm": "確認",
|
||||
"disable": "無効化",
|
||||
"disable_confirm_prompt": "二要素認証を無効にするにはパスワードを入力してください。",
|
||||
"password_required": "パスワードが必要です",
|
||||
"code_required": "確認コードが必要です",
|
||||
"code_invalid": "確認コードが無効です。認証アプリを確認してもう一度お試しください。"
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "アプリパスワード",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "アプリパスワードが削除されました",
|
||||
"add_error": "アプリパスワードを作成できませんでした",
|
||||
"remove_error": "アプリパスワードを削除できませんでした",
|
||||
"none": "アプリパスワードは設定されていません"
|
||||
"none": "アプリパスワードは設定されていません",
|
||||
"done": "完了",
|
||||
"expires_label": "有効期限(任意)",
|
||||
"copy_now_warning": "今すぐこのパスワードをコピーしてください - 再表示されません。",
|
||||
"allowed_ips_label": "許可するIP(任意)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "カンマまたは空白で区切ります。空欄の場合、すべてのIPを許可します。"
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "APIキー",
|
||||
"description": "サーバーと直接通信するスクリプトや連携用にAPIキーを作成します",
|
||||
"name_label": "キー名",
|
||||
"name_placeholder": "例: バックアップスクリプト、CIランナー",
|
||||
"copy_now_warning": "このAPIキーを今すぐコピーしてください - 二度と表示されません。",
|
||||
"added": "APIキーを作成しました",
|
||||
"removed": "APIキーを削除しました",
|
||||
"add_error": "APIキーの作成に失敗しました",
|
||||
"remove_error": "APIキーの削除に失敗しました",
|
||||
"none": "APIキーは設定されていません"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "保存時の暗号化",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, other {#個の条件}}",
|
||||
"actions_count": "{count, plural, other {#個のアクション}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "外部",
|
||||
"managed_by_tooltip": "{source} によって管理されています。そのアプリで編集するか、生の Sieve エディターを使用してください。"
|
||||
},
|
||||
"templates": {
|
||||
"title": "メールテンプレート",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
|
||||
"empty_search": "検索に一致する連絡先がありません",
|
||||
"empty_search_hint": "別の検索語をお試しください",
|
||||
"empty_filtered": "フィルターに一致する連絡先がありません",
|
||||
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
|
||||
"clear_search": "検索をクリア",
|
||||
"import_vcard": "vCardをインポート",
|
||||
"delete_confirm_title": "連絡先を削除",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"import_to_smime": "S/MIME にインポート",
|
||||
"cert_already_imported": "証明書はすでにインポートされています",
|
||||
"cert_imported": "証明書をインポートしました",
|
||||
"cert_import_failed": "証明書のインポートに失敗しました"
|
||||
"cert_import_failed": "証明書のインポートに失敗しました",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "その他の操作",
|
||||
"age_years": "{count}歳",
|
||||
"years_since": "{count}年前"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新しい連絡先",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "有効なメールアドレスを入力してください",
|
||||
"email_error_inline": "メールアドレスの形式が正しくありません",
|
||||
"save_failed": "連絡先の保存に失敗しました",
|
||||
"delete": "削除"
|
||||
"delete": "削除",
|
||||
"upload_photo": "写真をアップロード",
|
||||
"remove_photo": "写真を削除",
|
||||
"photo_hint": "JPGまたはPNG、最大10MB。リサイズされます。",
|
||||
"photo_too_large": "画像が大きすぎます (最大10MB)",
|
||||
"photo_invalid": "無効な画像ファイル",
|
||||
"change_photo": "変更"
|
||||
},
|
||||
"groups": {
|
||||
"create": "新しいグループ",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "連絡先の作成に失敗しました",
|
||||
"error_update": "連絡先の更新に失敗しました",
|
||||
"error_delete": "連絡先の削除に失敗しました"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "開く",
|
||||
"edit": "編集",
|
||||
"send_email": "メール送信",
|
||||
"add_to_group": "グループに追加",
|
||||
"export_vcard": "vCardとしてエクスポート",
|
||||
"delete": "削除",
|
||||
"call": "電話する",
|
||||
"duplicate": "複製",
|
||||
"print": "印刷"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "フィルター",
|
||||
"select": "選択",
|
||||
"clear": "クリア",
|
||||
"close": "閉じる",
|
||||
"title": "詳細フィルター",
|
||||
"organization": "会社",
|
||||
"organization_placeholder": "例: Acme",
|
||||
"job_title": "役職",
|
||||
"job_title_placeholder": "例: デザイナー",
|
||||
"location": "場所",
|
||||
"location_placeholder": "都市または国",
|
||||
"email_domain": "メールドメイン",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "誕生月",
|
||||
"any_month": "すべての月",
|
||||
"has_email": "メールあり",
|
||||
"has_phone": "電話あり",
|
||||
"has_photo": "写真あり"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "仮承諾",
|
||||
"needs_action": "未回答",
|
||||
"remove": "削除",
|
||||
"edit": "編集",
|
||||
"email_placeholder": "メールアドレスを追加または連絡先を検索",
|
||||
"send_invitations": "参加者に招待を送信",
|
||||
"status_summary": "{accepted}人承諾、{pending}人保留",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "폴더",
|
||||
"mail": "메일",
|
||||
"nav_label": "내비게이션",
|
||||
"add_app": "앱"
|
||||
"add_app": "앱",
|
||||
"shared": "공유됨"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "사이드바 앱",
|
||||
@@ -670,14 +671,24 @@
|
||||
"contacts": "연락처",
|
||||
"encryption": "암호화",
|
||||
"sidebar_apps": "사이드바 앱",
|
||||
"notifications": "알림"
|
||||
"notifications": "알림",
|
||||
"layout": "레이아웃",
|
||||
"reading": "읽기",
|
||||
"composing": "작성",
|
||||
"content_senders": "콘텐츠 및 발신자",
|
||||
"about_data": "정보 및 데이터",
|
||||
"debug": "디버그"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "일반",
|
||||
"account": "계정 및 인증",
|
||||
"organization": "메일 정리",
|
||||
"apps": "앱",
|
||||
"system": "시스템"
|
||||
"system": "시스템",
|
||||
"appearance": "모양",
|
||||
"mail": "메일",
|
||||
"privacy": "개인정보 및 보안",
|
||||
"advanced": "고급"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "화면 설정",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "통합 메일함",
|
||||
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "컬러풀한 사이드바 아이콘",
|
||||
"description": "폴더와 태그 아이콘을 유형별로 색상 표시합니다(받은편지함 파란색, 스팸 빨간색, 보낸편지함 녹색 등). 모노크롬 사이드바를 원하면 비활성화하세요."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "2단계 인증이 꺼졌어요",
|
||||
"enable_error": "2단계 인증을 켜지 못했어요",
|
||||
"disable_error": "2단계 인증을 끄지 못했어요",
|
||||
"setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:"
|
||||
"setup_instructions": "이 URL을 인증 앱(Google Authenticator, Authy 등)에 복사해 주세요:",
|
||||
"verification_code": "확인 코드",
|
||||
"confirm": "확인",
|
||||
"disable": "비활성화",
|
||||
"disable_confirm_prompt": "2단계 인증을 비활성화하려면 비밀번호를 입력하세요.",
|
||||
"password_required": "비밀번호가 필요합니다",
|
||||
"code_required": "확인 코드가 필요합니다",
|
||||
"code_invalid": "유효하지 않은 확인 코드입니다. 인증 앱을 확인하고 다시 시도하세요."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "앱 비밀번호",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "앱 비밀번호가 삭제되었어요",
|
||||
"add_error": "앱 비밀번호를 만들지 못했어요",
|
||||
"remove_error": "앱 비밀번호를 삭제하지 못했어요",
|
||||
"none": "설정된 앱 비밀번호가 없어요"
|
||||
"none": "설정된 앱 비밀번호가 없어요",
|
||||
"done": "완료",
|
||||
"expires_label": "만료 (선택 사항)",
|
||||
"copy_now_warning": "지금 이 비밀번호를 복사하세요 - 다시 표시되지 않습니다.",
|
||||
"allowed_ips_label": "허용된 IP(선택)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "쉼표 또는 공백으로 구분합니다. 비워두면 모든 IP가 허용됩니다."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API 키",
|
||||
"description": "서버와 직접 통신하는 스크립트 및 통합용 API 키를 만듭니다",
|
||||
"name_label": "키 이름",
|
||||
"name_placeholder": "예: 백업 스크립트, CI 러너",
|
||||
"copy_now_warning": "이 API 키를 지금 복사하세요 - 다시 표시되지 않습니다.",
|
||||
"added": "API 키가 생성되었습니다",
|
||||
"removed": "API 키가 삭제되었습니다",
|
||||
"add_error": "API 키 생성에 실패했습니다",
|
||||
"remove_error": "API 키 삭제에 실패했습니다",
|
||||
"none": "구성된 API 키가 없습니다"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "저장 데이터 암호화",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "조건 {count}개",
|
||||
"actions_count": "동작 {count}개"
|
||||
}
|
||||
},
|
||||
"origin_external": "외부",
|
||||
"managed_by_tooltip": "{source}에서 관리됩니다. 해당 앱에서 편집하거나 원시 Sieve 편집기를 사용하세요."
|
||||
},
|
||||
"templates": {
|
||||
"title": "이메일 템플릿",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
|
||||
"empty_search": "검색된 연락처가 없어요",
|
||||
"empty_search_hint": "다른 검색어로 다시 시도해 보세요",
|
||||
"empty_filtered": "필터에 일치하는 연락처가 없어요",
|
||||
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
|
||||
"clear_search": "검색 지우기",
|
||||
"import_vcard": "vCard 가져오기",
|
||||
"delete_confirm_title": "연락처 삭제",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"calendar": "캘린더",
|
||||
"calendar_uri": "캘린더 URL",
|
||||
"scheduling_uri": "스케줄링 URL",
|
||||
"freebusy_uri": "Free/Busy URL"
|
||||
"freebusy_uri": "Free/Busy URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "작업 더보기",
|
||||
"age_years": "{count}세",
|
||||
"years_since": "{count}년 전"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "새 연락처",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
|
||||
"email_error_inline": "이메일 형식이 잘못되었어요",
|
||||
"save_failed": "연락처를 저장하지 못했어요",
|
||||
"delete": "삭제"
|
||||
"delete": "삭제",
|
||||
"upload_photo": "사진 업로드",
|
||||
"remove_photo": "사진 제거",
|
||||
"photo_hint": "JPG 또는 PNG, 최도 10MB. 크기가 조정됩니다.",
|
||||
"photo_too_large": "이미지가 너무 큽니다 (최대 10MB)",
|
||||
"photo_invalid": "잘못된 이미지 파일",
|
||||
"change_photo": "변경"
|
||||
},
|
||||
"groups": {
|
||||
"create": "새 그룹",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "연락처를 만들지 못했어요",
|
||||
"error_update": "연락처를 업데이트하지 못했어요",
|
||||
"error_delete": "연락처를 삭제하지 못했어요"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "열기",
|
||||
"edit": "편집",
|
||||
"send_email": "이메일 보내기",
|
||||
"add_to_group": "그룹에 추가",
|
||||
"export_vcard": "vCard로 내보내기",
|
||||
"delete": "삭제",
|
||||
"call": "통화",
|
||||
"duplicate": "복제",
|
||||
"print": "인쇄"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "필터",
|
||||
"select": "선택",
|
||||
"clear": "지우기",
|
||||
"close": "닫기",
|
||||
"title": "고급 필터",
|
||||
"organization": "회사",
|
||||
"organization_placeholder": "예: Acme",
|
||||
"job_title": "직책",
|
||||
"job_title_placeholder": "예: 디자이너",
|
||||
"location": "위치",
|
||||
"location_placeholder": "도시 또는 국가",
|
||||
"email_domain": "이메일 도메인",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "생일 월",
|
||||
"any_month": "모든 달",
|
||||
"has_email": "이메일 있음",
|
||||
"has_phone": "전화번호 있음",
|
||||
"has_photo": "사진 있음"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "미정",
|
||||
"needs_action": "응답 필요",
|
||||
"remove": "삭제",
|
||||
"edit": "편집",
|
||||
"email_placeholder": "이메일을 추가하거나 연락처를 검색하세요",
|
||||
"send_invitations": "참석자에게 초대장 보내기",
|
||||
"status_summary": "{accepted}명 수락, {pending}명 대기 중",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Mapes",
|
||||
"mail": "Pasts",
|
||||
"nav_label": "Navigācija",
|
||||
"add_app": "Lietotnes"
|
||||
"add_app": "Lietotnes",
|
||||
"shared": "Koplietots"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Sānu joslas lietotnes",
|
||||
@@ -670,14 +671,24 @@
|
||||
"contacts": "Kontakti",
|
||||
"encryption": "Šifrēšana",
|
||||
"sidebar_apps": "Sānu joslas lietotnes",
|
||||
"notifications": "Paziņojumi"
|
||||
"notifications": "Paziņojumi",
|
||||
"layout": "Izkārtojums",
|
||||
"reading": "Lasīšana",
|
||||
"composing": "Rakstīšana",
|
||||
"content_senders": "Saturs un sūtītāji",
|
||||
"about_data": "Par un dati",
|
||||
"debug": "Atkļūdošana"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Vispārīgi",
|
||||
"account": "Konts un identitāte",
|
||||
"organization": "Pasta organizēšana",
|
||||
"apps": "Lietotnes",
|
||||
"system": "Sistēma"
|
||||
"system": "Sistēma",
|
||||
"appearance": "Izskats",
|
||||
"mail": "Pasts",
|
||||
"privacy": "Privātums un drošība",
|
||||
"advanced": "Papildu"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Izskats",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Apvienotā pastkaste",
|
||||
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Krāsainas sānjoslas ikonas",
|
||||
"description": "Iekrāsojiet mapju un birku ikonas pēc to veida (zila Iesūtne, sarkana Mēstules, zaļa Nosūtītie utt.). Atspējojiet, lai iegūtu vienkrāsainu sānjoslu."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "2FA ir izslēgta",
|
||||
"enable_error": "Neizdevās iespējot 2FA",
|
||||
"disable_error": "Neizdevās izslēgt 2FA",
|
||||
"setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):"
|
||||
"setup_instructions": "Nokopējiet šo URL savā autentifikācijas lietotnē (Google Authenticator, Authy utt.):",
|
||||
"verification_code": "Verifikācijas kods",
|
||||
"confirm": "Apstiprināt",
|
||||
"disable": "Atspējot",
|
||||
"disable_confirm_prompt": "Ievadiet paroli, lai atspējotu divpakāpju autentifikāciju.",
|
||||
"password_required": "Nepieciešama parole",
|
||||
"code_required": "Nepieciešams verifikācijas kods",
|
||||
"code_invalid": "Nederīgs verifikācijas kods. Pārbaudiet autentifikācijas lietotni un mēģiniet vēlreiz."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Lietotņu paroles",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Lietotnes parole izdzēsta",
|
||||
"add_error": "Neizdevās izveidot lietotnes paroli",
|
||||
"remove_error": "Neizdevās izdzēst lietotnes paroli",
|
||||
"none": "Lietotņu paroles nav iestatītas"
|
||||
"none": "Lietotņu paroles nav iestatītas",
|
||||
"done": "Gatavs",
|
||||
"expires_label": "Derīguma termiņš (pēc izvēles)",
|
||||
"copy_now_warning": "Kopējiet šo paroli tagad - tā vairs netiks rādīta.",
|
||||
"allowed_ips_label": "Atļautās IP (neobligāti)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Atdalītas ar komatu vai atstarpi. Atstājiet tukšu, lai atļautu jebkuru IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API atslēgas",
|
||||
"description": "Izveidojiet API atslēgas skriptiem un integrācijām, kas tieši sazinās ar serveri",
|
||||
"name_label": "Atslēgas nosaukums",
|
||||
"name_placeholder": "piem. Dublēšanas skripts, CI palaidējs",
|
||||
"copy_now_warning": "Kopējiet šo API atslēgu tagad - tā vairs netiks parādīta.",
|
||||
"added": "API atslēga izveidota",
|
||||
"removed": "API atslēga noņemta",
|
||||
"add_error": "Neizdevās izveidot API atslēgu",
|
||||
"remove_error": "Neizdevās noņemt API atslēgu",
|
||||
"none": "API atslēgas nav konfigurētas"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Krātuves šifrēšana",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# nosacījums} other {# nosacījumi}}",
|
||||
"actions_count": "{count, plural, one {# darbība} other {# darbības}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Ārējs",
|
||||
"managed_by_tooltip": "Pārvalda {source}. Rediģējiet to šajā lietotnē vai izmantojiet neapstrādāto Sieve redaktoru."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Vēstuļu veidnes",
|
||||
@@ -1701,6 +1743,8 @@
|
||||
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
|
||||
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
|
||||
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
|
||||
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
|
||||
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
|
||||
"clear_search": "Notīrīt meklēšanu",
|
||||
"import_vcard": "vCard imports",
|
||||
"delete_confirm_title": "Dzēst kontaktu",
|
||||
@@ -1778,7 +1822,33 @@
|
||||
"calendar": "Kalendārs",
|
||||
"calendar_uri": "Kalendāra URL",
|
||||
"scheduling_uri": "Plānošanas URL",
|
||||
"freebusy_uri": "Pieejamības URL"
|
||||
"freebusy_uri": "Pieejamības URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Citas darbības",
|
||||
"age_years": "{count, plural, one {1 gads} other {# gadi}}",
|
||||
"years_since": "{count, plural, one {pirms 1 gada} other {pirms # gadiem}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Jauns kontakts",
|
||||
@@ -1874,7 +1944,13 @@
|
||||
"email_invalid": "Ievadiet derīgu e-pasta adresi",
|
||||
"email_error_inline": "Nederīgs e-pasta formāts",
|
||||
"save_failed": "Neizdevās saglabāt kontaktu",
|
||||
"delete": "Dzēst"
|
||||
"delete": "Dzēst",
|
||||
"upload_photo": "Augšupielādēt foto",
|
||||
"remove_photo": "Noņemt foto",
|
||||
"photo_hint": "JPG vai PNG, līdz 10 MB. Tiks mainīts izmērs.",
|
||||
"photo_too_large": "Attēls ir pārāk liels (maks. 10 MB)",
|
||||
"photo_invalid": "Nederīgs attēla fails",
|
||||
"change_photo": "Mainīt"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Jauna grupa",
|
||||
@@ -1938,7 +2014,38 @@
|
||||
"rename_category": "Pārdēvēt kategoriju",
|
||||
"category_name_label": "Kategorijas nosaukums",
|
||||
"category_renamed": "Kategorija pārdēvēta",
|
||||
"category_rename_failed": "Neizdevās pārdēvēt kategoriju"
|
||||
"category_rename_failed": "Neizdevās pārdēvēt kategoriju",
|
||||
"context_menu": {
|
||||
"open": "Atvērt",
|
||||
"edit": "Rediģēt",
|
||||
"send_email": "Sūtīt e-pastu",
|
||||
"add_to_group": "Pievienot grupai",
|
||||
"export_vcard": "Eksportēt kā vCard",
|
||||
"delete": "Dzēst",
|
||||
"call": "Zvanīt",
|
||||
"duplicate": "Dublēt",
|
||||
"print": "Drukāt"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtri",
|
||||
"select": "Atlasīt",
|
||||
"clear": "Notīrīt",
|
||||
"close": "Aizvērt",
|
||||
"title": "Paplašinātie filtri",
|
||||
"organization": "Uzņēmums",
|
||||
"organization_placeholder": "piem. Acme",
|
||||
"job_title": "Amats",
|
||||
"job_title_placeholder": "piem. Dizainers",
|
||||
"location": "Atrašanās vieta",
|
||||
"location_placeholder": "Pilsēta vai valsts",
|
||||
"email_domain": "E-pasta domēns",
|
||||
"email_domain_placeholder": "piemers.lv",
|
||||
"birthday_month": "Dzimšanas diena",
|
||||
"any_month": "Jebkurš mēnesis",
|
||||
"has_email": "Ar e-pastu",
|
||||
"has_phone": "Ar tālruni",
|
||||
"has_photo": "Ar foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendārs",
|
||||
@@ -2012,6 +2119,7 @@
|
||||
"tentative": "Pagaidām",
|
||||
"needs_action": "Nepieciešama atbilde",
|
||||
"remove": "Noņemt",
|
||||
"edit": "Rediģēt",
|
||||
"email_placeholder": "Pievienojiet adresi vai kontaktu",
|
||||
"send_invitations": "Sūtīt uzaicinājumus dalībniekiem",
|
||||
"status_summary": "{accepted} pieņēmuši, {pending} gaida",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Mappen",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigatie",
|
||||
"add_app": "Apps"
|
||||
"add_app": "Apps",
|
||||
"shared": "Gedeeld"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Zijbalk-apps",
|
||||
@@ -670,14 +671,24 @@
|
||||
"files": "Bestanden",
|
||||
"contacts": "Contacten",
|
||||
"sidebar_apps": "Zijbalk-apps",
|
||||
"notifications": "Meldingen"
|
||||
"notifications": "Meldingen",
|
||||
"layout": "Indeling",
|
||||
"reading": "Lezen",
|
||||
"composing": "Opstellen",
|
||||
"content_senders": "Inhoud en afzenders",
|
||||
"about_data": "Over en gegevens",
|
||||
"debug": "Debuggen"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Algemeen",
|
||||
"account": "Account & identiteit",
|
||||
"organization": "E-mailorganisatie",
|
||||
"apps": "Apps",
|
||||
"system": "Systeem"
|
||||
"system": "Systeem",
|
||||
"appearance": "Weergave",
|
||||
"mail": "E-mail",
|
||||
"privacy": "Privacy en beveiliging",
|
||||
"advanced": "Geavanceerd"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Uiterlijk",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Gecombineerd postvak",
|
||||
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Gekleurde zijbalkpictogrammen",
|
||||
"description": "Kleur map- en tagpictogrammen op type (blauw Postvak IN, rood Spam, groen Verzonden, enz.). Schakel uit voor een monochrome zijbalk."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Tweefactorauthenticatie uitgeschakeld",
|
||||
"enable_error": "Kan 2FA niet inschakelen",
|
||||
"disable_error": "Kan 2FA niet uitschakelen",
|
||||
"setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):"
|
||||
"setup_instructions": "Kopieer deze URL naar uw authenticator-app (Google Authenticator, Authy, etc.):",
|
||||
"verification_code": "Verificatiecode",
|
||||
"confirm": "Bevestigen",
|
||||
"disable": "Uitschakelen",
|
||||
"disable_confirm_prompt": "Voer uw wachtwoord in om tweefactorauthenticatie uit te schakelen.",
|
||||
"password_required": "Wachtwoord is vereist",
|
||||
"code_required": "Verificatiecode is vereist",
|
||||
"code_invalid": "Ongeldige verificatiecode. Controleer uw authenticator-app en probeer het opnieuw."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "App-wachtwoorden",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "App-wachtwoord verwijderd",
|
||||
"add_error": "Kan app-wachtwoord niet aanmaken",
|
||||
"remove_error": "Kan app-wachtwoord niet verwijderen",
|
||||
"none": "Geen app-wachtwoorden geconfigureerd"
|
||||
"none": "Geen app-wachtwoorden geconfigureerd",
|
||||
"done": "Klaar",
|
||||
"expires_label": "Verloopt (optioneel)",
|
||||
"copy_now_warning": "Kopieer dit wachtwoord nu - het wordt niet opnieuw weergegeven.",
|
||||
"allowed_ips_label": "Toegestane IP's (optioneel)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Gescheiden door komma of spatie. Laat leeg om elk IP toe te staan."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API-sleutels",
|
||||
"description": "Maak API-sleutels voor scripts en integraties die rechtstreeks met de server praten",
|
||||
"name_label": "Sleutelnaam",
|
||||
"name_placeholder": "bijv. Back-upscript, CI-runner",
|
||||
"copy_now_warning": "Kopieer deze API-sleutel nu - hij wordt niet opnieuw getoond.",
|
||||
"added": "API-sleutel aangemaakt",
|
||||
"removed": "API-sleutel verwijderd",
|
||||
"add_error": "API-sleutel aanmaken mislukt",
|
||||
"remove_error": "API-sleutel verwijderen mislukt",
|
||||
"none": "Geen API-sleutels geconfigureerd"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Versleuteling in rust",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# voorwaarde} other {# voorwaarden}}",
|
||||
"actions_count": "{count, plural, one {# actie} other {# acties}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Extern",
|
||||
"managed_by_tooltip": "Beheerd door {source}. Bewerk het in die app of gebruik de ruwe Sieve-editor."
|
||||
},
|
||||
"templates": {
|
||||
"title": "E-mailsjablonen",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
|
||||
"empty_search": "Geen contacten gevonden",
|
||||
"empty_search_hint": "Probeer een andere zoekterm",
|
||||
"empty_filtered": "Geen contacten voldoen aan uw filters",
|
||||
"empty_filtered_hint": "Pas filters aan of wis ze",
|
||||
"clear_search": "Zoekopdracht wissen",
|
||||
"import_vcard": "vCard importeren",
|
||||
"delete_confirm_title": "Contact verwijderen",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"import_to_smime": "Importeren naar S/MIME",
|
||||
"cert_already_imported": "Certificaat is al geïmporteerd",
|
||||
"cert_imported": "Certificaat geïmporteerd",
|
||||
"cert_import_failed": "Importeren van certificaat mislukt"
|
||||
"cert_import_failed": "Importeren van certificaat mislukt",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Meer acties",
|
||||
"age_years": "{count, plural, one {1 jaar} other {# jaar}}",
|
||||
"years_since": "{count, plural, one {1 jaar geleden} other {# jaar geleden}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nieuw contact",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Voer een geldig e-mailadres in",
|
||||
"email_error_inline": "Ongeldig e-mailformaat",
|
||||
"save_failed": "Kon contact niet opslaan",
|
||||
"delete": "Verwijderen"
|
||||
"delete": "Verwijderen",
|
||||
"upload_photo": "Foto uploaden",
|
||||
"remove_photo": "Foto verwijderen",
|
||||
"photo_hint": "JPG of PNG, tot 10 MB. Wordt verkleind.",
|
||||
"photo_too_large": "Afbeelding is te groot (max 10 MB)",
|
||||
"photo_invalid": "Ongeldig afbeeldingsbestand",
|
||||
"change_photo": "Wijzigen"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nieuwe groep",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Kon contact niet aanmaken",
|
||||
"error_update": "Kon contact niet bijwerken",
|
||||
"error_delete": "Kon contact niet verwijderen"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Openen",
|
||||
"edit": "Bewerken",
|
||||
"send_email": "E-mail verzenden",
|
||||
"add_to_group": "Aan groep toevoegen",
|
||||
"export_vcard": "Exporteren als vCard",
|
||||
"delete": "Verwijderen",
|
||||
"call": "Bellen",
|
||||
"duplicate": "Dupliceren",
|
||||
"print": "Afdrukken"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filters",
|
||||
"select": "Selecteren",
|
||||
"clear": "Wissen",
|
||||
"close": "Sluiten",
|
||||
"title": "Geavanceerde filters",
|
||||
"organization": "Bedrijf",
|
||||
"organization_placeholder": "bijv. Acme",
|
||||
"job_title": "Functie",
|
||||
"job_title_placeholder": "bijv. Ontwerper",
|
||||
"location": "Locatie",
|
||||
"location_placeholder": "Stad of land",
|
||||
"email_domain": "E-maildomein",
|
||||
"email_domain_placeholder": "voorbeeld.nl",
|
||||
"birthday_month": "Verjaardag in",
|
||||
"any_month": "Elke maand",
|
||||
"has_email": "Met e-mail",
|
||||
"has_phone": "Met telefoon",
|
||||
"has_photo": "Met foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Voorlopig",
|
||||
"needs_action": "Reactie vereist",
|
||||
"remove": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"email_placeholder": "E-mailadres toevoegen of contacten zoeken",
|
||||
"send_invitations": "Uitnodigingen sturen naar deelnemers",
|
||||
"status_summary": "{accepted} geaccepteerd, {pending} in afwachting",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Foldery",
|
||||
"mail": "Poczta",
|
||||
"nav_label": "Nawigacja",
|
||||
"add_app": "Aplikacje"
|
||||
"add_app": "Aplikacje",
|
||||
"shared": "Udostępnione"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Aplikacje paska bocznego",
|
||||
@@ -670,14 +671,24 @@
|
||||
"contacts": "Kontakty",
|
||||
"encryption": "Szyfrowanie",
|
||||
"sidebar_apps": "Aplikacje paska bocznego",
|
||||
"notifications": "Powiadomienia"
|
||||
"notifications": "Powiadomienia",
|
||||
"layout": "Układ",
|
||||
"reading": "Czytanie",
|
||||
"composing": "Tworzenie",
|
||||
"content_senders": "Treść i nadawcy",
|
||||
"about_data": "O programie i dane",
|
||||
"debug": "Debugowanie"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Ogólne",
|
||||
"account": "Konto i tożsamość",
|
||||
"organization": "Organizacja poczty",
|
||||
"apps": "Aplikacje",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"appearance": "Wygląd",
|
||||
"mail": "Poczta",
|
||||
"privacy": "Prywatność i bezpieczeństwo",
|
||||
"advanced": "Zaawansowane"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Wygląd",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Wspólna skrzynka",
|
||||
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Kolorowe ikony paska bocznego",
|
||||
"description": "Koloruj ikony folderów i tagów według typu (niebieska Skrzynka odbiorcza, czerwona Spam, zielona Wysłane itp.). Wyłącz, aby uzyskać monochromatyczny pasek boczny."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Uwierzytelnianie dwuskładnikowe wyłączone",
|
||||
"enable_error": "Nie udało się włączyć 2FA",
|
||||
"disable_error": "Nie udało się wyłączyć 2FA",
|
||||
"setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):"
|
||||
"setup_instructions": "Skopiuj ten adres URL do swojej aplikacji uwierzytelniającej (Google Authenticator, Authy itp.):",
|
||||
"verification_code": "Kod weryfikacyjny",
|
||||
"confirm": "Potwierdź",
|
||||
"disable": "Wyłącz",
|
||||
"disable_confirm_prompt": "Wprowadź hasło, aby wyłączyć uwierzytelnianie dwuskładnikowe.",
|
||||
"password_required": "Hasło jest wymagane",
|
||||
"code_required": "Kod weryfikacyjny jest wymagany",
|
||||
"code_invalid": "Nieprawidłowy kod weryfikacyjny. Sprawdź aplikację uwierzytelniającą i spróbuj ponownie."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Hasła aplikacji",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Hasło aplikacji zostało usunięte",
|
||||
"add_error": "Nie udało się utworzyć hasła aplikacji",
|
||||
"remove_error": "Nie udało się usunąć hasła aplikacji",
|
||||
"none": "Brak skonfigurowanych haseł aplikacji"
|
||||
"none": "Brak skonfigurowanych haseł aplikacji",
|
||||
"done": "Gotowe",
|
||||
"expires_label": "Wygasa (opcjonalnie)",
|
||||
"copy_now_warning": "Skopiuj to hasło teraz - nie zostanie ponownie wyświetlone.",
|
||||
"allowed_ips_label": "Dozwolone adresy IP (opcjonalnie)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Oddzielone przecinkiem lub spacją. Pozostaw puste, aby zezwolić na dowolny IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "Klucze API",
|
||||
"description": "Twórz klucze API dla skryptów i integracji komunikujących się bezpośrednio z serwerem",
|
||||
"name_label": "Nazwa klucza",
|
||||
"name_placeholder": "np. Skrypt kopii zapasowej, CI runner",
|
||||
"copy_now_warning": "Skopiuj ten klucz API teraz - nie zostanie pokazany ponownie.",
|
||||
"added": "Klucz API utworzony",
|
||||
"removed": "Klucz API usunięty",
|
||||
"add_error": "Nie udało się utworzyć klucza API",
|
||||
"remove_error": "Nie udało się usunąć klucza API",
|
||||
"none": "Brak skonfigurowanych kluczy API"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Szyfrowanie danych w spoczynku",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# warunek} other {# warunków}}",
|
||||
"actions_count": "{count, plural, one {# akcja} other {# akcji}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Zewnętrzny",
|
||||
"managed_by_tooltip": "Zarządzane przez {source}. Edytuj w tej aplikacji lub użyj surowego edytora Sieve."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Szablony wiadomości e-mail",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
|
||||
"empty_search": "Żadne kontakty nie pasują do wyszukiwania",
|
||||
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
|
||||
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
|
||||
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
|
||||
"clear_search": "Wyczyść wyszukiwanie",
|
||||
"import_vcard": "Importuj vCard",
|
||||
"delete_confirm_title": "Usuń kontakt",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"calendar": "Kalendarz",
|
||||
"calendar_uri": "Adres URL kalendarza",
|
||||
"scheduling_uri": "Adres URL planowania",
|
||||
"freebusy_uri": "Adres URL wolny/zajęty"
|
||||
"freebusy_uri": "Adres URL wolny/zajęty",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Więcej działań",
|
||||
"age_years": "{count, plural, one {1 rok} few {# lata} many {# lat} other {# lat}}",
|
||||
"years_since": "{count, plural, one {1 rok temu} few {# lata temu} many {# lat temu} other {# lat temu}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nowy kontakt",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Wprowadź prawidłowy adres e-mail",
|
||||
"email_error_inline": "Nieprawidłowy format adresu e-mail",
|
||||
"save_failed": "Nie udało się zapisać kontaktu",
|
||||
"delete": "Usuń"
|
||||
"delete": "Usuń",
|
||||
"upload_photo": "Prześlij zdjęcie",
|
||||
"remove_photo": "Usuń zdjęcie",
|
||||
"photo_hint": "JPG lub PNG, do 10 MB. Zostanie zmniejszone.",
|
||||
"photo_too_large": "Obraz jest za duży (maks. 10 MB)",
|
||||
"photo_invalid": "Nieprawidłowy plik obrazu",
|
||||
"change_photo": "Zmień"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nowa grupa",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Nie udało się utworzyć kontaktu",
|
||||
"error_update": "Nie udało się zaktualizować kontaktu",
|
||||
"error_delete": "Nie udało się usunąć kontaktu"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Otwórz",
|
||||
"edit": "Edytuj",
|
||||
"send_email": "Wyślij e-mail",
|
||||
"add_to_group": "Dodaj do grupy",
|
||||
"export_vcard": "Eksportuj jako vCard",
|
||||
"delete": "Usuń",
|
||||
"call": "Zadzwoń",
|
||||
"duplicate": "Duplikuj",
|
||||
"print": "Drukuj"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtry",
|
||||
"select": "Zaznacz",
|
||||
"clear": "Wyczyść",
|
||||
"close": "Zamknij",
|
||||
"title": "Filtry zaawansowane",
|
||||
"organization": "Firma",
|
||||
"organization_placeholder": "np. Acme",
|
||||
"job_title": "Stanowisko",
|
||||
"job_title_placeholder": "np. Projektant",
|
||||
"location": "Lokalizacja",
|
||||
"location_placeholder": "Miasto lub kraj",
|
||||
"email_domain": "Domena e-mail",
|
||||
"email_domain_placeholder": "przyklad.pl",
|
||||
"birthday_month": "Urodziny w",
|
||||
"any_month": "Dowolny miesiąc",
|
||||
"has_email": "Z e-mailem",
|
||||
"has_phone": "Z telefonem",
|
||||
"has_photo": "Ze zdjęciem"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Wstępnie",
|
||||
"needs_action": "Wymaga działania",
|
||||
"remove": "Usuń",
|
||||
"edit": "Edytuj",
|
||||
"email_placeholder": "Dodaj adres e-mail lub wyszukaj kontakty",
|
||||
"send_invitations": "Wyślij zaproszenia do uczestników",
|
||||
"status_summary": "{accepted} zaakceptowano, {pending} oczekuje",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Pastas",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navegação",
|
||||
"add_app": "Apps"
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartilhado"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Apps da barra lateral",
|
||||
@@ -670,14 +671,24 @@
|
||||
"files": "Arquivos",
|
||||
"contacts": "Contactos",
|
||||
"sidebar_apps": "Apps da barra lateral",
|
||||
"notifications": "Notificações"
|
||||
"notifications": "Notificações",
|
||||
"layout": "Layout",
|
||||
"reading": "Leitura",
|
||||
"composing": "Composição",
|
||||
"content_senders": "Conteúdo e remetentes",
|
||||
"about_data": "Sobre e dados",
|
||||
"debug": "Depuração"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Geral",
|
||||
"account": "Conta e identidade",
|
||||
"organization": "Organização de e-mail",
|
||||
"apps": "Aplicativos",
|
||||
"system": "Sistema"
|
||||
"system": "Sistema",
|
||||
"appearance": "Aparência",
|
||||
"mail": "Correio",
|
||||
"privacy": "Privacidade e segurança",
|
||||
"advanced": "Avançado"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aparência",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Caixa de correio unificada",
|
||||
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Ícones coloridos na barra lateral",
|
||||
"description": "Colorir ícones de pastas e etiquetas por tipo (Caixa de entrada azul, Spam vermelho, Enviados verde, etc.). Desative para uma barra lateral monocromática."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Autenticação de dois fatores desabilitada",
|
||||
"enable_error": "Não foi possível habilitar a 2FA",
|
||||
"disable_error": "Não foi possível desabilitar a 2FA",
|
||||
"setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):"
|
||||
"setup_instructions": "Copie esta URL para seu aplicativo de autenticação (Google Authenticator, Authy, etc.):",
|
||||
"verification_code": "Código de verificação",
|
||||
"confirm": "Confirmar",
|
||||
"disable": "Desativar",
|
||||
"disable_confirm_prompt": "Digite sua senha para desativar a autenticação de dois fatores.",
|
||||
"password_required": "A senha é obrigatória",
|
||||
"code_required": "O código de verificação é obrigatório",
|
||||
"code_invalid": "Código de verificação inválido. Verifique seu aplicativo autenticador e tente novamente."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Senhas de aplicativo",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Senha de aplicativo removida",
|
||||
"add_error": "Não foi possível criar a senha de aplicativo",
|
||||
"remove_error": "Não foi possível remover a senha de aplicativo",
|
||||
"none": "Nenhuma senha de aplicativo configurada"
|
||||
"none": "Nenhuma senha de aplicativo configurada",
|
||||
"done": "Concluído",
|
||||
"expires_label": "Expira (opcional)",
|
||||
"copy_now_warning": "Copie esta senha agora - ela não será exibida novamente.",
|
||||
"allowed_ips_label": "IPs permitidos (opcional)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Separados por vírgula ou espaço. Deixe vazio para permitir qualquer IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "Chaves de API",
|
||||
"description": "Crie chaves de API para scripts e integrações que se comunicam diretamente com o servidor",
|
||||
"name_label": "Nome da chave",
|
||||
"name_placeholder": "ex. Script de backup, CI runner",
|
||||
"copy_now_warning": "Copie esta chave de API agora - ela não será mostrada novamente.",
|
||||
"added": "Chave de API criada",
|
||||
"removed": "Chave de API removida",
|
||||
"add_error": "Falha ao criar chave de API",
|
||||
"remove_error": "Falha ao remover chave de API",
|
||||
"none": "Nenhuma chave de API configurada"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Criptografia em repouso",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# condição} other {# condições}}",
|
||||
"actions_count": "{count, plural, one {# ação} other {# ações}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Externo",
|
||||
"managed_by_tooltip": "Gerenciado por {source}. Edite nesse aplicativo ou use o editor Sieve bruto."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Modelos de e-mail",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
|
||||
"empty_search": "Nenhum contato encontrado",
|
||||
"empty_search_hint": "Tente outro termo de pesquisa",
|
||||
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
|
||||
"empty_filtered_hint": "Ajuste ou limpe os filtros",
|
||||
"clear_search": "Limpar pesquisa",
|
||||
"import_vcard": "Importar vCard",
|
||||
"delete_confirm_title": "Excluir contato",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"import_to_smime": "Importar para S/MIME",
|
||||
"cert_already_imported": "Certificado já importado",
|
||||
"cert_imported": "Certificado importado",
|
||||
"cert_import_failed": "Falha ao importar o certificado"
|
||||
"cert_import_failed": "Falha ao importar o certificado",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Mais ações",
|
||||
"age_years": "{count, plural, one {1 ano} other {# anos}}",
|
||||
"years_since": "{count, plural, one {há 1 ano} other {há # anos}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Novo contato",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Por favor, insira um endereço de e-mail válido",
|
||||
"email_error_inline": "Formato de e-mail inválido",
|
||||
"save_failed": "Falha ao salvar contato",
|
||||
"delete": "Excluir"
|
||||
"delete": "Excluir",
|
||||
"upload_photo": "Carregar foto",
|
||||
"remove_photo": "Remover foto",
|
||||
"photo_hint": "JPG ou PNG, até 10 MB. Será redimensionada.",
|
||||
"photo_too_large": "A imagem é muito grande (máx. 10 MB)",
|
||||
"photo_invalid": "Arquivo de imagem inválido",
|
||||
"change_photo": "Alterar"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Novo grupo",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Falha ao criar contato",
|
||||
"error_update": "Falha ao atualizar contato",
|
||||
"error_delete": "Falha ao excluir contato"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Abrir",
|
||||
"edit": "Editar",
|
||||
"send_email": "Enviar e-mail",
|
||||
"add_to_group": "Adicionar ao grupo",
|
||||
"export_vcard": "Exportar como vCard",
|
||||
"delete": "Excluir",
|
||||
"call": "Ligar",
|
||||
"duplicate": "Duplicar",
|
||||
"print": "Imprimir"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Filtros",
|
||||
"select": "Selecionar",
|
||||
"clear": "Limpar",
|
||||
"close": "Fechar",
|
||||
"title": "Filtros avançados",
|
||||
"organization": "Empresa",
|
||||
"organization_placeholder": "ex. Acme",
|
||||
"job_title": "Cargo",
|
||||
"job_title_placeholder": "ex. Designer",
|
||||
"location": "Localização",
|
||||
"location_placeholder": "Cidade ou país",
|
||||
"email_domain": "Domínio de e-mail",
|
||||
"email_domain_placeholder": "exemplo.com",
|
||||
"birthday_month": "Aniversário em",
|
||||
"any_month": "Qualquer mês",
|
||||
"has_email": "Com e-mail",
|
||||
"has_phone": "Com telefone",
|
||||
"has_photo": "Com foto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Provisório",
|
||||
"needs_action": "Aguardando resposta",
|
||||
"remove": "Remover",
|
||||
"edit": "Editar",
|
||||
"email_placeholder": "Adicionar endereço de e-mail ou pesquisar contatos",
|
||||
"send_invitations": "Enviar convites aos participantes",
|
||||
"status_summary": "{accepted} aceito(s), {pending} pendente(s)",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Папки",
|
||||
"mail": "Почта",
|
||||
"nav_label": "Навигация",
|
||||
"add_app": "Приложения"
|
||||
"add_app": "Приложения",
|
||||
"shared": "Общие"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Приложения боковой панели",
|
||||
@@ -670,14 +671,24 @@
|
||||
"contacts": "Контакты",
|
||||
"encryption": "Шифрование",
|
||||
"sidebar_apps": "Приложения боковой панели",
|
||||
"notifications": "Уведомления"
|
||||
"notifications": "Уведомления",
|
||||
"layout": "Макет",
|
||||
"reading": "Чтение",
|
||||
"composing": "Написание",
|
||||
"content_senders": "Содержимое и отправители",
|
||||
"about_data": "О программе и данные",
|
||||
"debug": "Отладка"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Общие",
|
||||
"account": "Аккаунт и удостоверение",
|
||||
"organization": "Организация почты",
|
||||
"apps": "Приложения",
|
||||
"system": "Система"
|
||||
"system": "Система",
|
||||
"appearance": "Внешний вид",
|
||||
"mail": "Почта",
|
||||
"privacy": "Конфиденциальность и безопасность",
|
||||
"advanced": "Дополнительно"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Внешний вид",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Общий почтовый ящик",
|
||||
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Цветные значки боковой панели",
|
||||
"description": "Окрашивать значки папок и тегов по типу (синий «Входящие», красный «Спам», зелёный «Отправленные» и т. д.). Отключите для монохромной боковой панели."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Двухфакторная аутентификация отключена",
|
||||
"enable_error": "Не удалось включить 2FA",
|
||||
"disable_error": "Не удалось отключить 2FA",
|
||||
"setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):"
|
||||
"setup_instructions": "Скопируйте этот URL в приложение-аутентификатор (Google Authenticator, Authy и др.):",
|
||||
"verification_code": "Код подтверждения",
|
||||
"confirm": "Подтвердить",
|
||||
"disable": "Отключить",
|
||||
"disable_confirm_prompt": "Введите пароль, чтобы отключить двухфакторную аутентификацию.",
|
||||
"password_required": "Требуется пароль",
|
||||
"code_required": "Требуется код подтверждения",
|
||||
"code_invalid": "Неверный код подтверждения. Проверьте приложение-аутентификатор и попробуйте снова."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Пароли приложений",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Пароль приложения удалён",
|
||||
"add_error": "Не удалось создать пароль приложения",
|
||||
"remove_error": "Не удалось удалить пароль приложения",
|
||||
"none": "Пароли приложений не настроены"
|
||||
"none": "Пароли приложений не настроены",
|
||||
"done": "Готово",
|
||||
"expires_label": "Срок действия (необязательно)",
|
||||
"copy_now_warning": "Скопируйте этот пароль сейчас - он больше не будет показан.",
|
||||
"allowed_ips_label": "Разрешённые IP (необязательно)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Через запятую или пробел. Оставьте пустым, чтобы разрешить любой IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API-ключи",
|
||||
"description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
|
||||
"name_label": "Название ключа",
|
||||
"name_placeholder": "напр. Скрипт резервного копирования, CI runner",
|
||||
"copy_now_warning": "Скопируйте этот API-ключ сейчас – он больше не будет показан.",
|
||||
"added": "API-ключ создан",
|
||||
"removed": "API-ключ удалён",
|
||||
"add_error": "Не удалось создать API-ключ",
|
||||
"remove_error": "Не удалось удалить API-ключ",
|
||||
"none": "API-ключи не настроены"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Шифрование хранилища",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# условие} other {# условий}}",
|
||||
"actions_count": "{count, plural, one {# действие} other {# действий}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Внешнее",
|
||||
"managed_by_tooltip": "Управляется {source}. Редактируйте в этом приложении или используйте редактор Sieve."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблоны писем",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
|
||||
"empty_search": "Нет контактов, соответствующих вашему запросу",
|
||||
"empty_search_hint": "Попробуйте другой поисковый запрос",
|
||||
"empty_filtered": "Нет контактов, соответствующих фильтрам",
|
||||
"empty_filtered_hint": "Измените или очистите фильтры",
|
||||
"clear_search": "Очистить поиск",
|
||||
"import_vcard": "Импорт vCard",
|
||||
"delete_confirm_title": "Удалить контакт",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"calendar": "Календарь",
|
||||
"calendar_uri": "URL календаря",
|
||||
"scheduling_uri": "URL планирования",
|
||||
"freebusy_uri": "URL доступности"
|
||||
"freebusy_uri": "URL доступности",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Другие действия",
|
||||
"age_years": "{count, plural, one {# год} few {# года} many {# лет} other {# лет}}",
|
||||
"years_since": "{count, plural, one {# год назад} few {# года назад} many {# лет назад} other {# лет назад}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Новый контакт",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Введите корректный адрес электронной почты",
|
||||
"email_error_inline": "Неверный формат email",
|
||||
"save_failed": "Не удалось сохранить контакт",
|
||||
"delete": "Удалить"
|
||||
"delete": "Удалить",
|
||||
"upload_photo": "Загрузить фото",
|
||||
"remove_photo": "Удалить фото",
|
||||
"photo_hint": "JPG или PNG, до 10 МБ. Будет уменьшено.",
|
||||
"photo_too_large": "Изображение слишком большое (макс. 10 МБ)",
|
||||
"photo_invalid": "Недопустимый файл изображения",
|
||||
"change_photo": "Изменить"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Новая группа",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Не удалось создать контакт",
|
||||
"error_update": "Не удалось обновить контакт",
|
||||
"error_delete": "Не удалось удалить контакт"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Открыть",
|
||||
"edit": "Изменить",
|
||||
"send_email": "Отправить письмо",
|
||||
"add_to_group": "Добавить в группу",
|
||||
"export_vcard": "Экспортировать как vCard",
|
||||
"delete": "Удалить",
|
||||
"call": "Позвонить",
|
||||
"duplicate": "Дублировать",
|
||||
"print": "Печать"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Фильтры",
|
||||
"select": "Выбрать",
|
||||
"clear": "Очистить",
|
||||
"close": "Закрыть",
|
||||
"title": "Расширенные фильтры",
|
||||
"organization": "Компания",
|
||||
"organization_placeholder": "напр. Acme",
|
||||
"job_title": "Должность",
|
||||
"job_title_placeholder": "напр. Дизайнер",
|
||||
"location": "Местоположение",
|
||||
"location_placeholder": "Город или страна",
|
||||
"email_domain": "Домен e-mail",
|
||||
"email_domain_placeholder": "primer.ru",
|
||||
"birthday_month": "День рождения в",
|
||||
"any_month": "Любой месяц",
|
||||
"has_email": "С эл. почтой",
|
||||
"has_phone": "С телефоном",
|
||||
"has_photo": "С фото"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Предварительно",
|
||||
"needs_action": "Требует ответа",
|
||||
"remove": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"email_placeholder": "Добавьте адрес или найдите контакт",
|
||||
"send_invitations": "Отправить приглашения участникам",
|
||||
"status_summary": "{accepted} принято, {pending} ожидает",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "Папки",
|
||||
"mail": "Пошта",
|
||||
"nav_label": "Навігація",
|
||||
"add_app": "програми"
|
||||
"add_app": "програми",
|
||||
"shared": "Спільні"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "Програми бічної панелі",
|
||||
@@ -670,14 +671,24 @@
|
||||
"contacts": "Контакти",
|
||||
"encryption": "Шифрування",
|
||||
"sidebar_apps": "Програми бічної панелі",
|
||||
"notifications": "Сповіщення"
|
||||
"notifications": "Сповіщення",
|
||||
"layout": "Макет",
|
||||
"reading": "Читання",
|
||||
"composing": "Написання",
|
||||
"content_senders": "Вміст і відправники",
|
||||
"about_data": "Про програму та дані",
|
||||
"debug": "Налагодження"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "Загальний",
|
||||
"account": "Обліковий запис і ідентифікатор",
|
||||
"organization": "Організація пошти",
|
||||
"apps": "програми",
|
||||
"system": "система"
|
||||
"system": "система",
|
||||
"appearance": "Вигляд",
|
||||
"mail": "Пошта",
|
||||
"privacy": "Конфіденційність і безпека",
|
||||
"advanced": "Додатково"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Зовнішній вигляд",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "Спільна поштова скринька",
|
||||
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Кольорові значки бічної панелі",
|
||||
"description": "Забарвлюйте значки папок і тегів за типом (синя «Вхідні», червоний «Спам», зелена «Надіслані» тощо). Вимкніть для монохромної бічної панелі."
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "Двофакторну автентифікацію вимкнено",
|
||||
"enable_error": "Не вдалося ввімкнути 2FA",
|
||||
"disable_error": "Не вдалося вимкнути 2FA",
|
||||
"setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):"
|
||||
"setup_instructions": "Скопіюйте цю URL-адресу в програму автентифікації (Google Authenticator, Authy тощо):",
|
||||
"verification_code": "Код підтвердження",
|
||||
"confirm": "Підтвердити",
|
||||
"disable": "Вимкнути",
|
||||
"disable_confirm_prompt": "Введіть пароль, щоб вимкнути двофакторну автентифікацію.",
|
||||
"password_required": "Потрібен пароль",
|
||||
"code_required": "Потрібен код підтвердження",
|
||||
"code_invalid": "Недійсний код підтвердження. Перевірте додаток автентифікації та спробуйте ще раз."
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "Паролі програм",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "Пароль програми видалено",
|
||||
"add_error": "Не вдалося створити пароль програми",
|
||||
"remove_error": "Не вдалося видалити пароль програми",
|
||||
"none": "Паролі програм не налаштовано"
|
||||
"none": "Паролі програм не налаштовано",
|
||||
"done": "Готово",
|
||||
"expires_label": "Термін дії (необов’язково)",
|
||||
"copy_now_warning": "Скопіюйте цей пароль зараз - він більше не буде показаний.",
|
||||
"allowed_ips_label": "Дозволені IP (необов’язково)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "Через кому або пробіл. Залиште порожнім, щоб дозволити будь-який IP."
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API-ключі",
|
||||
"description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
|
||||
"name_label": "Назва ключа",
|
||||
"name_placeholder": "напр. Скрипт резервної копії, CI runner",
|
||||
"copy_now_warning": "Скопіюйте цей API-ключ зараз – він більше не буде показаний.",
|
||||
"added": "API-ключ створено",
|
||||
"removed": "API-ключ видалено",
|
||||
"add_error": "Не вдалося створити API-ключ",
|
||||
"remove_error": "Не вдалося видалити API-ключ",
|
||||
"none": "API-ключі не налаштовано"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "Шифрування в спокої",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# умова} few {# умови} many {# умов} other {# умов}}",
|
||||
"actions_count": "{count, plural, one {# дія} few {# дії} many {# дій} other {# дій}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "Зовнішнє",
|
||||
"managed_by_tooltip": "Керується {source}. Редагуйте в тому додатку або використовуйте редактор Sieve."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблони електронної пошти",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
|
||||
"empty_search": "Жоден контакт не відповідає вашому пошуку",
|
||||
"empty_search_hint": "Спробуйте інший термін пошуку",
|
||||
"empty_filtered": "Жоден контакт не відповідає фільтрам",
|
||||
"empty_filtered_hint": "Змініть або очистіть фільтри",
|
||||
"clear_search": "Очистити пошук",
|
||||
"import_vcard": "Імпорт vCard",
|
||||
"delete_confirm_title": "Видалити контакт",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"calendar": "Календар",
|
||||
"calendar_uri": "URL-адреса календаря",
|
||||
"scheduling_uri": "URL-адреса планування",
|
||||
"freebusy_uri": "Вільний/зайнятий URL"
|
||||
"freebusy_uri": "Вільний/зайнятий URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "Більше дій",
|
||||
"age_years": "{count, plural, one {# рік} few {# роки} many {# років} other {# років}}",
|
||||
"years_since": "{count, plural, one {# рік тому} few {# роки тому} many {# років тому} other {# років тому}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Новий контакт",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "Введіть дійсну електронну адресу",
|
||||
"email_error_inline": "Недійсний формат електронної пошти",
|
||||
"save_failed": "Не вдалося зберегти контакт",
|
||||
"delete": "Видалити"
|
||||
"delete": "Видалити",
|
||||
"upload_photo": "Завантажити фото",
|
||||
"remove_photo": "Видалити фото",
|
||||
"photo_hint": "JPG або PNG, до 10 МБ. Буде зменшено.",
|
||||
"photo_too_large": "Зображення завелике (макс. 10 МБ)",
|
||||
"photo_invalid": "Недійсний файл зображення",
|
||||
"change_photo": "Змінити"
|
||||
},
|
||||
"groups": {
|
||||
"create": "Нова група",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "Не вдалося створити контакт",
|
||||
"error_update": "Не вдалося оновити контакт",
|
||||
"error_delete": "Не вдалося видалити контакт"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Відкрити",
|
||||
"edit": "Редагувати",
|
||||
"send_email": "Надіслати лист",
|
||||
"add_to_group": "Додати до групи",
|
||||
"export_vcard": "Експортувати як vCard",
|
||||
"delete": "Видалити",
|
||||
"call": "Зателефонувати",
|
||||
"duplicate": "Дублювати",
|
||||
"print": "Друк"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "Фільтри",
|
||||
"select": "Вибрати",
|
||||
"clear": "Очистити",
|
||||
"close": "Закрити",
|
||||
"title": "Розширені фільтри",
|
||||
"organization": "Компанія",
|
||||
"organization_placeholder": "напр. Acme",
|
||||
"job_title": "Посада",
|
||||
"job_title_placeholder": "напр. Дизайнер",
|
||||
"location": "Місцезнаходження",
|
||||
"location_placeholder": "Місто або країна",
|
||||
"email_domain": "Домен e-mail",
|
||||
"email_domain_placeholder": "priklad.ua",
|
||||
"birthday_month": "День народження в",
|
||||
"any_month": "Будь-який місяць",
|
||||
"has_email": "З ел. поштою",
|
||||
"has_phone": "З телефоном",
|
||||
"has_photo": "З фото"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "Орієнтовний",
|
||||
"needs_action": "Потребує дії",
|
||||
"remove": "видалити",
|
||||
"edit": "Редагувати",
|
||||
"email_placeholder": "Додайте електронну адресу або знайдіть контакти",
|
||||
"send_invitations": "Надішліть запрошення учасникам",
|
||||
"status_summary": "{accepted} прийнято, {pending} очікує на розгляд",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"folders": "文件夹",
|
||||
"mail": "邮件",
|
||||
"nav_label": "导航",
|
||||
"add_app": "应用"
|
||||
"add_app": "应用",
|
||||
"shared": "共享"
|
||||
},
|
||||
"sidebar_apps": {
|
||||
"modal_title": "侧边栏应用",
|
||||
@@ -670,14 +671,24 @@
|
||||
"contacts": "联系人",
|
||||
"encryption": "加密",
|
||||
"sidebar_apps": "侧边栏应用",
|
||||
"notifications": "通知"
|
||||
"notifications": "通知",
|
||||
"layout": "布局",
|
||||
"reading": "阅读",
|
||||
"composing": "撰写",
|
||||
"content_senders": "内容和发件人",
|
||||
"about_data": "关于和数据",
|
||||
"debug": "调试"
|
||||
},
|
||||
"tab_groups": {
|
||||
"general": "通用",
|
||||
"account": "账户与身份",
|
||||
"organization": "邮件组织",
|
||||
"apps": "应用",
|
||||
"system": "系统"
|
||||
"system": "系统",
|
||||
"appearance": "外观",
|
||||
"mail": "邮件",
|
||||
"privacy": "隐私和安全",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外观",
|
||||
@@ -733,6 +744,10 @@
|
||||
"unified_mailbox": {
|
||||
"label": "统一邮箱",
|
||||
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "彩色侧边栏图标",
|
||||
"description": "按类型为文件夹和标签图标着色(蓝色收件箱、红色垃圾邮件、绿色已发送等)。禁用以获得单色侧边栏。"
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
@@ -1075,7 +1090,14 @@
|
||||
"disabled": "禁用双因素身份验证",
|
||||
"enable_error": "无法启用 2FA",
|
||||
"disable_error": "无法禁用 2FA",
|
||||
"setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:"
|
||||
"setup_instructions": "请将此 URL 复制到身份验证器应用(Google Authenticator、Authy 等)中:",
|
||||
"verification_code": "验证码",
|
||||
"confirm": "确认",
|
||||
"disable": "禁用",
|
||||
"disable_confirm_prompt": "输入密码以禁用双重身份验证。",
|
||||
"password_required": "需要密码",
|
||||
"code_required": "需要验证码",
|
||||
"code_invalid": "验证码无效。请检查您的身份验证应用并重试。"
|
||||
},
|
||||
"app_passwords": {
|
||||
"title": "应用密码",
|
||||
@@ -1092,7 +1114,25 @@
|
||||
"removed": "应用密码已删除",
|
||||
"add_error": "创建应用密码失败",
|
||||
"remove_error": "无法删除应用密码",
|
||||
"none": "未配置应用密码"
|
||||
"none": "未配置应用密码",
|
||||
"done": "完成",
|
||||
"expires_label": "过期时间(可选)",
|
||||
"copy_now_warning": "立即复制此密码--它将不再显示。",
|
||||
"allowed_ips_label": "允许的 IP(可选)",
|
||||
"allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
|
||||
"allowed_ips_hint": "用逗号或空格分隔。留空则允许任意 IP。"
|
||||
},
|
||||
"api_keys": {
|
||||
"title": "API 密钥",
|
||||
"description": "为直接与服务器通信的脚本和集成创建 API 密钥",
|
||||
"name_label": "密钥名称",
|
||||
"name_placeholder": "如:备份脚本、CI runner",
|
||||
"copy_now_warning": "请立即复制此 API 密钥 - 它将不会再次显示。",
|
||||
"added": "API 密钥已创建",
|
||||
"removed": "API 密钥已删除",
|
||||
"add_error": "创建 API 密钥失败",
|
||||
"remove_error": "删除 API 密钥失败",
|
||||
"none": "未配置 API 密钥"
|
||||
},
|
||||
"encryption": {
|
||||
"section_title": "静态加密",
|
||||
@@ -1415,7 +1455,9 @@
|
||||
"rule_summary": {
|
||||
"conditions_count": "{count, plural, one {# 个条件} other {# 个条件}}",
|
||||
"actions_count": "{count, plural, one {# 个操作} other {# 个操作}}"
|
||||
}
|
||||
},
|
||||
"origin_external": "外部",
|
||||
"managed_by_tooltip": "由 {source} 管理。请在该应用中编辑,或使用原始 Sieve 编辑器。"
|
||||
},
|
||||
"templates": {
|
||||
"title": "邮件模板",
|
||||
@@ -1705,6 +1747,8 @@
|
||||
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
|
||||
"empty_search": "没有符合您搜索条件的联系人",
|
||||
"empty_search_hint": "尝试不同的搜索词",
|
||||
"empty_filtered": "没有符合您筛选条件的联系人",
|
||||
"empty_filtered_hint": "请调整或清除筛选条件",
|
||||
"clear_search": "清除搜索",
|
||||
"import_vcard": "导入 vCard",
|
||||
"delete_confirm_title": "删除联系人",
|
||||
@@ -1782,7 +1826,33 @@
|
||||
"calendar": "日历",
|
||||
"calendar_uri": "日历 URL",
|
||||
"scheduling_uri": "调度 URL",
|
||||
"freebusy_uri": "空闲/忙碌 URL"
|
||||
"freebusy_uri": "空闲/忙碌 URL",
|
||||
"section_contact": "Contact details",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
"phone_default_label": "Phone",
|
||||
"address_default_label": "Address",
|
||||
"online_service_default_label": "Online",
|
||||
"organization_label": "Organization",
|
||||
"title_label": "Title",
|
||||
"role_label": "Role",
|
||||
"language_label": "Language",
|
||||
"related_default_label": "Related",
|
||||
"more_actions": "更多操作",
|
||||
"age_years": "{count}岁",
|
||||
"years_since": "{count}年前"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新联系人",
|
||||
@@ -1878,7 +1948,13 @@
|
||||
"email_invalid": "请输入有效的邮箱地址",
|
||||
"email_error_inline": "邮箱地址格式无效",
|
||||
"save_failed": "保存联系人失败",
|
||||
"delete": "删除"
|
||||
"delete": "删除",
|
||||
"upload_photo": "上传照片",
|
||||
"remove_photo": "移除照片",
|
||||
"photo_hint": "JPG 或 PNG, 最大 10MB。将会调整大小。",
|
||||
"photo_too_large": "图像过大 (最大 10MB)",
|
||||
"photo_invalid": "无效的图像文件",
|
||||
"change_photo": "更改"
|
||||
},
|
||||
"groups": {
|
||||
"create": "新建群组",
|
||||
@@ -1938,6 +2014,37 @@
|
||||
"error_create": "创建联系人失败",
|
||||
"error_update": "无法更新联系人",
|
||||
"error_delete": "删除联系人失败"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "打开",
|
||||
"edit": "编辑",
|
||||
"send_email": "发送邮件",
|
||||
"add_to_group": "加入群组",
|
||||
"export_vcard": "导出为 vCard",
|
||||
"delete": "删除",
|
||||
"call": "拨打电话",
|
||||
"duplicate": "复制",
|
||||
"print": "打印"
|
||||
},
|
||||
"filters": {
|
||||
"toggle": "筛选",
|
||||
"select": "选择",
|
||||
"clear": "清除",
|
||||
"close": "关闭",
|
||||
"title": "高级筛选",
|
||||
"organization": "公司",
|
||||
"organization_placeholder": "例如 Acme",
|
||||
"job_title": "职位",
|
||||
"job_title_placeholder": "例如 设计师",
|
||||
"location": "所在地",
|
||||
"location_placeholder": "城市或国家",
|
||||
"email_domain": "邮箱域名",
|
||||
"email_domain_placeholder": "example.com",
|
||||
"birthday_month": "生日月份",
|
||||
"any_month": "任意月份",
|
||||
"has_email": "有邮箱",
|
||||
"has_phone": "有电话",
|
||||
"has_photo": "有照片"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -2013,6 +2120,7 @@
|
||||
"tentative": "暂定",
|
||||
"needs_action": "待处理",
|
||||
"remove": "移除",
|
||||
"edit": "编辑",
|
||||
"email_placeholder": "添加邮箱地址或搜索联系人",
|
||||
"send_invitations": "向参与者发送邀请",
|
||||
"status_summary": "{accepted} 已接受,{pending} 待处理",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.14",
|
||||
"version": "1.5.0",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -32,59 +32,62 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tiptap/extension-color": "^3.20.4",
|
||||
"@tiptap/extension-image": "^3.20.4",
|
||||
"@tiptap/extension-link": "^3.20.4",
|
||||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
"@tiptap/extension-text-align": "^3.20.4",
|
||||
"@tiptap/extension-text-style": "^3.20.4",
|
||||
"@tiptap/extension-underline": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/react": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"asn1js": "^3.0.7",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tiptap/extension-color": "^3.22.4",
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-placeholder": "^3.22.4",
|
||||
"@tiptap/extension-text-align": "^3.22.4",
|
||||
"@tiptap/extension-text-style": "^3.22.4",
|
||||
"@tiptap/extension-underline": "^3.22.4",
|
||||
"@tiptap/pm": "^3.22.4",
|
||||
"@tiptap/react": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "^16.1.5",
|
||||
"next-intl": "^4.5.8",
|
||||
"pkijs": "^3.3.3",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^16.2.4",
|
||||
"next-intl": "^4.9.1",
|
||||
"otpauth": "^9.5.0",
|
||||
"pkijs": "^3.4.0",
|
||||
"postal-mime": "^2.7.4",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"webcrypto-liner": "^1.4.3",
|
||||
"zustand": "^5.0.9"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"eslint": "^9.39.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^17.0.0",
|
||||
"globals": "^17.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"elliptic": {
|
||||
|
||||
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -53,6 +53,7 @@ ENV_FILE="${SCRIPT_DIR}/.env.local"
|
||||
# Config values (defaults)
|
||||
CFG_APP_NAME="Bulwark Webmail"
|
||||
CFG_JMAP_SERVER_URL=""
|
||||
CFG_ALLOW_CUSTOM_JMAP_ENDPOINT="false"
|
||||
CFG_STALWART_FEATURES="true"
|
||||
CFG_OAUTH_ENABLED="false"
|
||||
CFG_OAUTH_ONLY="false"
|
||||
@@ -64,16 +65,23 @@ CFG_SETTINGS_SYNC_ENABLED="false"
|
||||
CFG_SETTINGS_DATA_DIR="./data/settings"
|
||||
CFG_LOG_FORMAT="text"
|
||||
CFG_LOG_LEVEL="info"
|
||||
CFG_APP_SHORT_NAME=""
|
||||
CFG_APP_DESCRIPTION=""
|
||||
CFG_LOGIN_COMPANY_NAME=""
|
||||
CFG_LOGIN_LOGO_LIGHT_URL=""
|
||||
CFG_LOGIN_LOGO_DARK_URL=""
|
||||
CFG_FAVICON_URL=""
|
||||
CFG_PWA_ICON_URL=""
|
||||
CFG_PWA_THEME_COLOR=""
|
||||
CFG_PWA_BACKGROUND_COLOR=""
|
||||
CFG_APP_LOGO_LIGHT_URL=""
|
||||
CFG_APP_LOGO_DARK_URL=""
|
||||
CFG_LOGIN_IMPRINT_URL=""
|
||||
CFG_LOGIN_PRIVACY_POLICY_URL=""
|
||||
CFG_LOGIN_WEBSITE_URL=""
|
||||
CFG_EXTENSION_DIRECTORY_URL=""
|
||||
CFG_DEPLOY_METHOD=""
|
||||
CFG_HOSTNAME="0.0.0.0"
|
||||
CFG_PORT="3000"
|
||||
|
||||
CURRENT_STEP=0
|
||||
@@ -593,9 +601,19 @@ screen_server_config() {
|
||||
prompt_yesno "Enable Stalwart-specific features?" "$CFG_STALWART_FEATURES" "CFG_STALWART_FEATURES"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Network${RESET}"
|
||||
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}"
|
||||
echo -e " ${DIM}Adds a \"JMAP Server\" field to the login form so users can${RESET}"
|
||||
echo -e " ${DIM}connect to any JMAP-compatible server (not just the one above).${RESET}"
|
||||
echo -e " ${DIM}The external server must allow this domain in its CORS headers.${RESET}"
|
||||
prompt_yesno "Allow users to override the JMAP server at login?" "$CFG_ALLOW_CUSTOM_JMAP_ENDPOINT" "CFG_ALLOW_CUSTOM_JMAP_ENDPOINT"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Network${RESET}"
|
||||
echo -e " ${DIM}The address the server binds to. Use \"0.0.0.0\" for all IPv4${RESET}"
|
||||
echo -e " ${DIM}interfaces or \"::\" for dual-stack IPv4+IPv6.${RESET}"
|
||||
echo ""
|
||||
prompt_value "Hostname" "$CFG_HOSTNAME" "CFG_HOSTNAME"
|
||||
echo ""
|
||||
echo -e " ${DIM}The port the web UI will listen on. Default is 3000.${RESET}"
|
||||
prompt_value "Port" "$CFG_PORT" "CFG_PORT"
|
||||
|
||||
draw_footer
|
||||
@@ -770,32 +788,65 @@ screen_login_customization() {
|
||||
echo -e " All fields are ${BOLD}optional${RESET}. Press Enter to skip any field."
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}App Identity${RESET}"
|
||||
echo -e " ${DIM}Short name used where space is limited (e.g. mobile home screen).${RESET}"
|
||||
echo -e " ${DIM}Defaults to the application name set in step 1.${RESET}"
|
||||
prompt_value "Short app name" "$CFG_APP_SHORT_NAME" "CFG_APP_SHORT_NAME"
|
||||
echo ""
|
||||
echo -e " ${DIM}Description shown by the OS when installing as a PWA.${RESET}"
|
||||
prompt_value "App description" "$CFG_APP_DESCRIPTION" "CFG_APP_DESCRIPTION"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Icons${RESET}"
|
||||
echo -e " ${DIM}Custom favicon for the browser tab (SVG recommended, 32×32 to 512×512px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank to use the default Bulwark favicon.${RESET}"
|
||||
prompt_value "Favicon URL" "$CFG_FAVICON_URL" "CFG_FAVICON_URL"
|
||||
echo ""
|
||||
echo -e " ${DIM}Source image for PWA icons (192×192 and 512×512 are auto-generated).${RESET}"
|
||||
echo -e " ${DIM}Falls back to FAVICON_URL if blank.${RESET}"
|
||||
prompt_value "PWA icon URL" "$CFG_PWA_ICON_URL" "CFG_PWA_ICON_URL"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}PWA Colors${RESET}"
|
||||
echo -e " ${DIM}Hex color (e.g. #3b82f6) used for the browser UI chrome when installed.${RESET}"
|
||||
prompt_value "PWA theme color" "$CFG_PWA_THEME_COLOR" "CFG_PWA_THEME_COLOR"
|
||||
echo ""
|
||||
echo -e " ${DIM}Hex color shown on the PWA splash screen while loading.${RESET}"
|
||||
prompt_value "PWA background color" "$CFG_PWA_BACKGROUND_COLOR" "CFG_PWA_BACKGROUND_COLOR"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Logos${RESET}"
|
||||
echo -e " ${DIM}App logo shown in the sidebar (SVG recommended, 24×24 to 128×128px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank for no sidebar logo.${RESET}"
|
||||
prompt_value "App logo URL (light mode)" "$CFG_APP_LOGO_LIGHT_URL" "CFG_APP_LOGO_LIGHT_URL"
|
||||
echo ""
|
||||
prompt_value "App logo URL (dark mode)" "$CFG_APP_LOGO_DARK_URL" "CFG_APP_LOGO_DARK_URL"
|
||||
echo ""
|
||||
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
|
||||
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
|
||||
echo ""
|
||||
echo -e " ${DIM}Custom logo URLs for the login page (SVG recommended, 32×32 to 512×512px)${RESET}"
|
||||
echo -e " ${DIM}Leave blank to use the default Bulwark logo.${RESET}"
|
||||
prompt_value "Login logo URL (light mode)" "$CFG_LOGIN_LOGO_LIGHT_URL" "CFG_LOGIN_LOGO_LIGHT_URL"
|
||||
echo ""
|
||||
prompt_value "Login logo URL (dark mode)" "$CFG_LOGIN_LOGO_DARK_URL" "CFG_LOGIN_LOGO_DARK_URL"
|
||||
echo ""
|
||||
|
||||
echo -e " ${BOLD}Login Page Links${RESET}"
|
||||
echo -e " ${DIM}Shown on the login page footer. Example: Acme Corp${RESET}"
|
||||
prompt_value "Company / organization name" "$CFG_LOGIN_COMPANY_NAME" "CFG_LOGIN_COMPANY_NAME"
|
||||
echo ""
|
||||
prompt_value "Website URL" "$CFG_LOGIN_WEBSITE_URL" "CFG_LOGIN_WEBSITE_URL"
|
||||
echo ""
|
||||
prompt_value "Imprint / legal notice URL" "$CFG_LOGIN_IMPRINT_URL" "CFG_LOGIN_IMPRINT_URL"
|
||||
echo ""
|
||||
prompt_value "Privacy policy URL" "$CFG_LOGIN_PRIVACY_POLICY_URL" "CFG_LOGIN_PRIVACY_POLICY_URL"
|
||||
echo ""
|
||||
|
||||
if [[ -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
echo -e " ${BOLD}Extension Directory${RESET}"
|
||||
echo -e " ${DIM}URL of the BulwarkMail extension directory for the admin marketplace.${RESET}"
|
||||
echo -e " ${DIM}Set this to enable browsing and installing plugins/themes.${RESET}"
|
||||
echo -e " ${DIM}Leave blank to disable the marketplace.${RESET}"
|
||||
prompt_value "Extension directory URL" "$CFG_EXTENSION_DIRECTORY_URL" "CFG_EXTENSION_DIRECTORY_URL"
|
||||
|
||||
if [[ -z "$CFG_APP_SHORT_NAME" && -z "$CFG_APP_DESCRIPTION" && -z "$CFG_LOGIN_COMPANY_NAME" && -z "$CFG_LOGIN_LOGO_LIGHT_URL" && -z "$CFG_LOGIN_LOGO_DARK_URL" && -z "$CFG_FAVICON_URL" && -z "$CFG_PWA_ICON_URL" && -z "$CFG_PWA_THEME_COLOR" && -z "$CFG_PWA_BACKGROUND_COLOR" && -z "$CFG_APP_LOGO_LIGHT_URL" && -z "$CFG_APP_LOGO_DARK_URL" && -z "$CFG_LOGIN_WEBSITE_URL" && -z "$CFG_LOGIN_IMPRINT_URL" && -z "$CFG_LOGIN_PRIVACY_POLICY_URL" && -z "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
|
||||
echo ""
|
||||
note "No branding configured. The app will use defaults."
|
||||
fi
|
||||
@@ -866,7 +917,9 @@ screen_summary() {
|
||||
echo -e " ${CYAN}${BOLD}SERVER${RESET}"
|
||||
echo -e " App Name .............. ${BOLD}${CFG_APP_NAME}${RESET}"
|
||||
echo -e " JMAP Server URL ....... ${BOLD}${CFG_JMAP_SERVER_URL}${RESET}"
|
||||
echo -e " Allow Custom JMAP ..... ${BOLD}${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}${RESET}"
|
||||
echo -e " Stalwart Features ..... ${BOLD}${CFG_STALWART_FEATURES}${RESET}"
|
||||
echo -e " Hostname .............. ${BOLD}${CFG_HOSTNAME}${RESET}"
|
||||
echo -e " Port .................. ${BOLD}${CFG_PORT}${RESET}"
|
||||
echo ""
|
||||
|
||||
@@ -905,9 +958,19 @@ screen_summary() {
|
||||
|
||||
# Login page
|
||||
echo -e " ${CYAN}${BOLD}BRANDING${RESET}"
|
||||
if [[ -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
if [[ -n "$CFG_APP_SHORT_NAME" || -n "$CFG_APP_DESCRIPTION" || -n "$CFG_LOGIN_COMPANY_NAME" || -n "$CFG_LOGIN_LOGO_LIGHT_URL" || -n "$CFG_LOGIN_LOGO_DARK_URL" || -n "$CFG_FAVICON_URL" || -n "$CFG_PWA_ICON_URL" || -n "$CFG_PWA_THEME_COLOR" || -n "$CFG_PWA_BACKGROUND_COLOR" || -n "$CFG_APP_LOGO_LIGHT_URL" || -n "$CFG_APP_LOGO_DARK_URL" || -n "$CFG_LOGIN_WEBSITE_URL" || -n "$CFG_LOGIN_IMPRINT_URL" || -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]]; then
|
||||
[[ -n "$CFG_APP_SHORT_NAME" ]] && \
|
||||
echo -e " Short Name ............ ${BOLD}${CFG_APP_SHORT_NAME}${RESET}"
|
||||
[[ -n "$CFG_APP_DESCRIPTION" ]] && \
|
||||
echo -e " Description ........... ${BOLD}${CFG_APP_DESCRIPTION}${RESET}"
|
||||
[[ -n "$CFG_FAVICON_URL" ]] && \
|
||||
echo -e " Favicon URL ........... ${BOLD}${CFG_FAVICON_URL}${RESET}"
|
||||
[[ -n "$CFG_PWA_ICON_URL" ]] && \
|
||||
echo -e " PWA Icon URL .......... ${BOLD}${CFG_PWA_ICON_URL}${RESET}"
|
||||
[[ -n "$CFG_PWA_THEME_COLOR" ]] && \
|
||||
echo -e " PWA Theme Color ....... ${BOLD}${CFG_PWA_THEME_COLOR}${RESET}"
|
||||
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && \
|
||||
echo -e " PWA Background Color .. ${BOLD}${CFG_PWA_BACKGROUND_COLOR}${RESET}"
|
||||
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && \
|
||||
echo -e " App Logo (light) ...... ${BOLD}${CFG_APP_LOGO_LIGHT_URL}${RESET}"
|
||||
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && \
|
||||
@@ -929,6 +992,13 @@ screen_summary() {
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Extensions
|
||||
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
|
||||
echo -e " ${CYAN}${BOLD}EXTENSIONS${RESET}"
|
||||
echo -e " Directory URL ......... ${BOLD}${CFG_EXTENSION_DIRECTORY_URL}${RESET}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Deployment
|
||||
echo -e " ${CYAN}${BOLD}DEPLOYMENT${RESET}"
|
||||
case "$CFG_DEPLOY_METHOD" in
|
||||
@@ -978,10 +1048,15 @@ write_env_file() {
|
||||
# -- JMAP Server (required) ---------------------------------------------------
|
||||
APP_NAME=${CFG_APP_NAME}
|
||||
JMAP_SERVER_URL=${CFG_JMAP_SERVER_URL}
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=${CFG_ALLOW_CUSTOM_JMAP_ENDPOINT}
|
||||
|
||||
# -- Stalwart Mail Server Integration -----------------------------------------
|
||||
STALWART_FEATURES=${CFG_STALWART_FEATURES}
|
||||
|
||||
# -- Server Listen Address ----------------------------------------------------
|
||||
HOSTNAME=${CFG_HOSTNAME}
|
||||
PORT=${CFG_PORT}
|
||||
|
||||
# -- OAuth / OpenID Connect ---------------------------------------------------
|
||||
OAUTH_ENABLED=${CFG_OAUTH_ENABLED}
|
||||
ENVEOF
|
||||
@@ -1025,7 +1100,12 @@ LOG_LEVEL=${CFG_LOG_LEVEL}
|
||||
# -- Branding ------------------------------------------------------------------
|
||||
ENVEOF
|
||||
|
||||
[[ -n "$CFG_APP_SHORT_NAME" ]] && echo "APP_SHORT_NAME=${CFG_APP_SHORT_NAME}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_DESCRIPTION" ]] && echo "APP_DESCRIPTION=${CFG_APP_DESCRIPTION}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_FAVICON_URL" ]] && echo "FAVICON_URL=${CFG_FAVICON_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_PWA_ICON_URL" ]] && echo "PWA_ICON_URL=${CFG_PWA_ICON_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_PWA_THEME_COLOR" ]] && echo "PWA_THEME_COLOR=${CFG_PWA_THEME_COLOR}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_PWA_BACKGROUND_COLOR" ]] && echo "PWA_BACKGROUND_COLOR=${CFG_PWA_BACKGROUND_COLOR}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_LOGO_LIGHT_URL" ]] && echo "APP_LOGO_LIGHT_URL=${CFG_APP_LOGO_LIGHT_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_APP_LOGO_DARK_URL" ]] && echo "APP_LOGO_DARK_URL=${CFG_APP_LOGO_DARK_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_LOGIN_COMPANY_NAME" ]] && echo "LOGIN_COMPANY_NAME=${CFG_LOGIN_COMPANY_NAME}" >> "$ENV_FILE"
|
||||
@@ -1035,6 +1115,29 @@ ENVEOF
|
||||
[[ -n "$CFG_LOGIN_PRIVACY_POLICY_URL" ]] && echo "LOGIN_PRIVACY_POLICY_URL=${CFG_LOGIN_PRIVACY_POLICY_URL}" >> "$ENV_FILE"
|
||||
[[ -n "$CFG_LOGIN_WEBSITE_URL" ]] && echo "LOGIN_WEBSITE_URL=${CFG_LOGIN_WEBSITE_URL}" >> "$ENV_FILE"
|
||||
|
||||
if [[ -n "$CFG_EXTENSION_DIRECTORY_URL" ]]; then
|
||||
cat >> "$ENV_FILE" << ENVEOF
|
||||
|
||||
# -- Extension Directory / Marketplace ----------------------------------------
|
||||
EXTENSION_DIRECTORY_URL=${CFG_EXTENSION_DIRECTORY_URL}
|
||||
ENVEOF
|
||||
fi
|
||||
|
||||
cat >> "$ENV_FILE" << ENVEOF
|
||||
|
||||
# -- Advanced (uncomment and set if needed) -----------------------------------
|
||||
# SESSION_SECRET_FILE=/run/secrets/session_secret
|
||||
# OAUTH_CLIENT_SECRET_FILE=/run/secrets/oauth_client_secret
|
||||
# OAUTH_SCOPES=openid profile email
|
||||
# OAUTH_EXTRA_SCOPES=
|
||||
# COOKIE_SAME_SITE=lax
|
||||
# COOKIE_SECURE=true
|
||||
# TRUSTED_PROXY_DEPTH=1
|
||||
# ALLOWED_FRAME_ANCESTORS='none'
|
||||
# ADMIN_DATA_DIR=./data/admin
|
||||
# ADMIN_SESSION_TTL=3600
|
||||
ENVEOF
|
||||
|
||||
echo -e " ${OK} Written ${BOLD}.env.local${RESET}"
|
||||
}
|
||||
|
||||
|
||||